fix(start-work): reuse registered opencode agent names

This commit is contained in:
YeonGyu-Kim
2026-04-08 16:18:26 +09:00
parent 24629643f0
commit 06b825dd74
13 changed files with 82 additions and 34 deletions
@@ -10,6 +10,7 @@ import {
getMainSessionID, getMainSessionID,
registerAgentName, registerAgentName,
isAgentRegistered, isAgentRegistered,
resolveRegisteredAgentName,
_resetForTesting, _resetForTesting,
} from "./state" } from "./state"
@@ -140,6 +141,15 @@ describe("claude-code-session-state", () => {
expect(isAgentRegistered("Atlas - Plan Executor")).toBe(true) 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("#given atlas display name with zero-width prefix", () => {
describe("#when checking registration without the zero-width prefix", () => { describe("#when checking registration without the zero-width prefix", () => {
test("#then it treats the display name as registered", () => { test("#then it treats the display name as registered", () => {
@@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined {
} }
const registeredAgentNames = new Set<string>() const registeredAgentNames = new Set<string>()
const registeredAgentAliases = new Map<string, string>()
const ZERO_WIDTH_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g 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 { export function registerAgentName(name: string): void {
const normalizedName = normalizeRegisteredAgentName(name) const normalizedName = normalizeRegisteredAgentName(name)
registeredAgentNames.add(normalizedName) registeredAgentNames.add(normalizedName)
if (!registeredAgentAliases.has(normalizedName)) {
registeredAgentAliases.set(normalizedName, name)
}
const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name)) const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name))
if (configKey !== normalizedName) { if (configKey !== normalizedName) {
registeredAgentNames.add(configKey) 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)) 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 */ /** @internal For testing only */
export function _resetForTesting(): void { export function _resetForTesting(): void {
_mainSessionID = undefined _mainSessionID = undefined
@@ -46,6 +62,7 @@ export function _resetForTesting(): void {
syncSubagentSessions.clear() syncSubagentSessions.clear()
sessionAgentMap.clear() sessionAgentMap.clear()
registeredAgentNames.clear() registeredAgentNames.clear()
registeredAgentAliases.clear()
} }
const sessionAgentMap = new Map<string, string>() const sessionAgentMap = new Map<string, string>()
@@ -1,6 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent" 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 { log } from "../../shared/logger"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import { HOOK_NAME } from "./hook-name" import { HOOK_NAME } from "./hook-name"
@@ -55,7 +58,9 @@ export async function injectBoulderContinuation(input: {
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
preferredSessionContext + preferredSessionContext +
worktreeContext worktreeContext
const continuationAgent = (agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined))?.replace(/\u200B/g, "") const continuationAgent = resolveRegisteredAgentName(
agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
)
if (!continuationAgent || !isAgentRegistered(continuationAgent)) { if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, { log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
@@ -1,4 +1,7 @@
import { updateSessionAgent } from "../../features/claude-code-session-state" import {
resolveRegisteredAgentName,
updateSessionAgent,
} from "../../features/claude-code-session-state"
import { import {
getCompactionAgentConfigCheckpoint, getCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint" } from "../../shared/compaction-agent-config-checkpoint"
@@ -66,6 +69,7 @@ export function createRecoveryLogic(
checkpointWithAgent, checkpointWithAgent,
currentPromptConfig, currentPromptConfig,
) )
const launchAgent = resolveRegisteredAgentName(expectedPromptConfig.agent)
const model = expectedPromptConfig.model const model = expectedPromptConfig.model
const tools = expectedPromptConfig.tools const tools = expectedPromptConfig.tools
@@ -81,7 +85,7 @@ export function createRecoveryLogic(
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
noReply: true, noReply: true,
agent: expectedPromptConfig.agent, agent: launchAgent ?? expectedPromptConfig.agent,
...(model ? { model } : {}), ...(model ? { model } : {}),
...(tools ? { tools } : {}), ...(tools ? { tools } : {}),
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)], parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
+8 -6
View File
@@ -1,8 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { isGptModel } from "../../agents/types" 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 { 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_TITLE = "NEVER Use Hephaestus with Non-GPT"
const TOAST_MESSAGE = [ const TOAST_MESSAGE = [
@@ -10,8 +14,6 @@ const TOAST_MESSAGE = [
"Hephaestus is trash without GPT.", "Hephaestus is trash without GPT.",
"For Claude/Kimi/GLM models, always use Sisyphus.", "For Claude/Kimi/GLM models, always use Sisyphus.",
].join("\n") ].join("\n")
const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
type NoHephaestusNonGptHookOptions = { type NoHephaestusNonGptHookOptions = {
allowNonGptModel?: boolean allowNonGptModel?: boolean
} }
@@ -54,9 +56,9 @@ export function createNoHephaestusNonGptHook(
if (allowNonGptModel) { if (allowNonGptModel) {
return return
} }
input.agent = "sisyphus" input.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus"
if (output?.message) { if (output?.message) {
output.message.agent = "sisyphus" output.message.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus"
} }
updateSessionAgent(input.sessionID, "sisyphus") updateSessionAgent(input.sessionID, "sisyphus")
} }
+8 -6
View File
@@ -1,8 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { isGptModel, isGpt5_4Model } from "../../agents/types" 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 { 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_TITLE = "NEVER Use Sisyphus with GPT"
const TOAST_MESSAGE = [ const TOAST_MESSAGE = [
@@ -10,8 +14,6 @@ const TOAST_MESSAGE = [
"Do NOT use Sisyphus with GPT (except GPT-5.4 which has specialized support).", "Do NOT use Sisyphus with GPT (except GPT-5.4 which has specialized support).",
"For GPT models (other than 5.4), always use Hephaestus.", "For GPT models (other than 5.4), always use Hephaestus.",
].join("\n") ].join("\n")
const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus")
function showToast(ctx: PluginInput, sessionID: string): void { function showToast(ctx: PluginInput, sessionID: string): void {
ctx.client.tui.showToast({ ctx.client.tui.showToast({
body: { body: {
@@ -43,9 +45,9 @@ export function createNoSisyphusGptHook(ctx: PluginInput) {
if (agentKey === "sisyphus" && modelID && isGptModel(modelID) && !isGpt5_4Model(modelID)) { if (agentKey === "sisyphus" && modelID && isGptModel(modelID) && !isGpt5_4Model(modelID)) {
showToast(ctx, input.sessionID) showToast(ctx, input.sessionID)
input.agent = "hephaestus" input.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus"
if (output?.message) { if (output?.message) {
output.message.agent = "hephaestus" output.message.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus"
} }
updateSessionAgent(input.sessionID, "hephaestus") updateSessionAgent(input.sessionID, "hephaestus")
} }
+3 -3
View File
@@ -9,7 +9,7 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { buildRetryModelPayload } from "./retry-model-payload" import { buildRetryModelPayload } from "./retry-model-payload"
import { getLastUserRetryParts } from "./last-user-retry-parts" import { getLastUserRetryParts } from "./last-user-retry-parts"
import { extractSessionMessages } from "./session-messages" 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 const SESSION_TTL_MS = 30 * 60 * 1000
@@ -133,14 +133,14 @@ export function createAutoRetryHelpers(deps: HookDeps) {
}) })
const retryAgent = resolvedAgent ?? getSessionAgent(sessionID) const retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
const launchAgent = resolveRegisteredAgentName(retryAgent)
sessionAwaitingFallbackResult.add(sessionID) sessionAwaitingFallbackResult.add(sessionID)
scheduleSessionFallbackTimeout(sessionID, retryAgent) scheduleSessionFallbackTimeout(sessionID, retryAgent)
await ctx.client.session.promptAsync({ await ctx.client.session.promptAsync({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
// Use config key to avoid HTTP header validation issues with display names ...(launchAgent ? { agent: launchAgent } : {}),
...(retryAgent ? { agent: retryAgent } : {}),
...retryModelPayload, ...retryModelPayload,
parts: retryParts, parts: retryParts,
}, },
+2 -1
View File
@@ -13,6 +13,7 @@ import {
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { import {
isAgentRegistered, isAgentRegistered,
resolveRegisteredAgentName,
updateSessionAgent, updateSessionAgent,
} from "../../features/claude-code-session-state" } from "../../features/claude-code-session-state"
import { detectWorktreePath } from "./worktree-detector" import { detectWorktreePath } from "./worktree-detector"
@@ -83,7 +84,7 @@ export function createStartWorkHook(ctx: PluginInput) {
: "sisyphus" : "sisyphus"
updateSessionAgent(input.sessionID, activeAgent) updateSessionAgent(input.sessionID, activeAgent)
if (output.message) { if (output.message) {
output.message["agent"] = activeAgent output.message["agent"] = resolveRegisteredAgentName(activeAgent) ?? activeAgent
} }
const existingState = readBoulderState(ctx.directory) const existingState = readBoulderState(ctx.directory)
@@ -5,7 +5,7 @@ import { injectContinuation } from "./continuation-injection"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
describe("injectContinuation", () => { 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 // given
let capturedAgent: string | undefined let capturedAgent: string | undefined
const ctx = { const ctx = {
@@ -40,7 +40,7 @@ describe("injectContinuation", () => {
}) })
// then // then
expect(capturedAgent).toBe("sisyphus") expect(capturedAgent).toBe("Sisyphus - Ultraworker")
}) })
test("inherits tools from resolved message info when reinjecting", async () => { test("inherits tools from resolved message info when reinjecting", async () => {
@@ -1,7 +1,10 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent" 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 { import {
createInternalAgentTextPart, createInternalAgentTextPart,
normalizeSDKResponse, normalizeSDKResponse,
@@ -127,6 +130,7 @@ export async function injectContinuation(args: {
} }
const promptAgent = normalizeAgentForPromptKey(agentName) const promptAgent = normalizeAgentForPromptKey(agentName)
const launchAgent = resolveRegisteredAgentName(agentName)
if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) { if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
@@ -168,7 +172,7 @@ ${todoList}`
try { try {
log(`[${HOOK_NAME}] Injecting continuation`, { log(`[${HOOK_NAME}] Injecting continuation`, {
sessionID, sessionID,
agent: promptAgent, agent: launchAgent ?? promptAgent,
model, model,
incompleteCount: freshIncompleteCount, incompleteCount: freshIncompleteCount,
}) })
@@ -183,7 +187,7 @@ ${todoList}`
await ctx.client.session.promptAsync({ await ctx.client.session.promptAsync({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
agent: promptAgent, agent: launchAgent ?? promptAgent,
...(launchModel ? { model: launchModel } : {}), ...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}), ...(launchVariant ? { variant: launchVariant } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}),
@@ -5,7 +5,10 @@ import * as skillLoader from "../features/opencode-skill-loader";
import type { OhMyOpenCodeConfig } from "../config"; import type { OhMyOpenCodeConfig } from "../config";
import type { PluginComponents } from "./plugin-components-loader"; import type { PluginComponents } from "./plugin-components-loader";
import { applyCommandConfig } from "./command-config-handler"; import { applyCommandConfig } from "./command-config-handler";
import { getAgentDisplayName } from "../shared/agent-display-names"; import {
getAgentDisplayName,
getAgentListDisplayName,
} from "../shared/agent-display-names";
function createPluginComponents(): PluginComponents { function createPluginComponents(): PluginComponents {
return { return {
@@ -97,7 +100,7 @@ describe("applyCommandConfig", () => {
expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill"); 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 // given
loadBuiltinCommandsSpy.mockReturnValue({ loadBuiltinCommandsSpy.mockReturnValue({
"start-work": { "start-work": {
@@ -119,10 +122,10 @@ describe("applyCommandConfig", () => {
// then // then
const commandConfig = config.command as Record<string, { agent?: string }>; const commandConfig = config.command as Record<string, { agent?: string }>;
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 // given
loadBuiltinCommandsSpy.mockReturnValue({ loadBuiltinCommandsSpy.mockReturnValue({
"start-work": { "start-work": {
@@ -144,6 +147,6 @@ describe("applyCommandConfig", () => {
// then // then
const commandConfig = config.command as Record<string, { agent?: string }>; const commandConfig = config.command as Record<string, { agent?: string }>;
expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas"));
}); });
}); });
@@ -1,7 +1,7 @@
import type { OhMyOpenCodeConfig } from "../config"; import type { OhMyOpenCodeConfig } from "../config";
import { import {
getAgentConfigKey, getAgentConfigKey,
getAgentDisplayName, getAgentListDisplayName,
} from "../shared/agent-display-names"; } from "../shared/agent-display-names";
import { import {
loadUserCommands, loadUserCommands,
@@ -99,7 +99,7 @@ export async function applyCommandConfig(params: {
function remapCommandAgentFields(commands: Record<string, Record<string, unknown>>): void { function remapCommandAgentFields(commands: Record<string, Record<string, unknown>>): void {
for (const cmd of Object.values(commands)) { for (const cmd of Object.values(commands)) {
if (cmd?.agent && typeof cmd.agent === "string") { if (cmd?.agent && typeof cmd.agent === "string") {
cmd.agent = getAgentDisplayName(getAgentConfigKey(cmd.agent)); cmd.agent = getAgentListDisplayName(getAgentConfigKey(cmd.agent));
} }
} }
} }
+2 -2
View File
@@ -1251,7 +1251,7 @@ describe("config-handler plugin loading error boundary (#1559)", () => {
}) })
describe("command agent routing coherence", () => { 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 //#given
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
mockResolvedValue: (value: Record<string, unknown>) => void mockResolvedValue: (value: Record<string, unknown>) => void
@@ -1291,7 +1291,7 @@ describe("command agent routing coherence", () => {
const agentConfig = config.agent as Record<string, unknown> const agentConfig = config.agent as Record<string, unknown>
const commandConfig = config.command as Record<string, { agent?: string }> const commandConfig = config.command as Record<string, { agent?: string }>
expect(Object.keys(agentConfig)).toContain(getAgentListDisplayName("atlas")) expect(Object.keys(agentConfig)).toContain(getAgentListDisplayName("atlas"))
expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")) expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas"))
}) })
}) })