Files
oh-my-opencode/src/plugin-handlers/agent-config-handler.test.ts
T
YeonGyu-Kim 4f88e0f4e1 fix(agents): restore canonical core agent ordering
Remap the core agent keys, default agent, and command routing back to\nlist display names so OpenCode's name-based sorting keeps the\ncanonical Sisyphus -> Hephaestus -> Prometheus -> Atlas order.\n\nAlso teach tool config lookups to resolve the prefixed list keys and\nadd regression tests that exercise the real ordering and routing path.
2026-04-09 12:50:05 +09:00

431 lines
13 KiB
TypeScript

/// <reference types="bun-types" />
import type { AgentConfig } from "@opencode-ai/sdk"
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import * as agents from "../agents"
import * as shared from "../shared"
import * as sisyphusJunior from "../agents/sisyphus-junior"
import type { OhMyOpenCodeConfig } from "../config"
import * as agentLoader from "../features/claude-code-agent-loader"
import * as skillLoader from "../features/opencode-skill-loader"
import type { LoadedSkill } from "../features/opencode-skill-loader"
import { getAgentDisplayName, getAgentListDisplayName } from "../shared/agent-display-names"
import { applyAgentConfig } from "./agent-config-handler"
import type { PluginComponents } from "./plugin-components-loader"
const BUILTIN_SISYPHUS_DISPLAY_NAME = getAgentListDisplayName("sisyphus")
const BUILTIN_SISYPHUS_JUNIOR_DISPLAY_NAME = getAgentDisplayName("sisyphus-junior")
const BUILTIN_MULTIMODAL_LOOKER_DISPLAY_NAME = getAgentDisplayName("multimodal-looker")
function createPluginComponents(): PluginComponents {
return {
commands: {},
skills: {},
agents: {},
mcpServers: {},
hooksConfigs: [],
plugins: [],
errors: [],
}
}
function createBaseConfig(): Record<string, unknown> {
return {
model: "anthropic/claude-opus-4-6",
agent: {},
}
}
function createPluginConfig(): OhMyOpenCodeConfig {
return {
sisyphus_agent: {
planner_enabled: false,
},
}
}
describe("applyAgentConfig builtin override protection", () => {
let createBuiltinAgentsSpy: ReturnType<typeof spyOn>
let createSisyphusJuniorAgentSpy: ReturnType<typeof spyOn>
let discoverConfigSourceSkillsSpy: ReturnType<typeof spyOn>
let discoverUserClaudeSkillsSpy: ReturnType<typeof spyOn>
let discoverProjectClaudeSkillsSpy: ReturnType<typeof spyOn>
let discoverOpencodeGlobalSkillsSpy: ReturnType<typeof spyOn>
let discoverOpencodeProjectSkillsSpy: ReturnType<typeof spyOn>
let discoverProjectAgentsSkillsSpy: ReturnType<typeof spyOn>
let discoverGlobalAgentsSkillsSpy: ReturnType<typeof spyOn>
let loadUserAgentsSpy: ReturnType<typeof spyOn>
let loadProjectAgentsSpy: ReturnType<typeof spyOn>
let migrateAgentConfigSpy: ReturnType<typeof spyOn>
let logSpy: ReturnType<typeof spyOn>
const builtinSisyphusConfig: AgentConfig = {
name: "Builtin Sisyphus",
prompt: "builtin prompt",
mode: "primary",
order: 1,
}
const builtinOracleConfig: AgentConfig = {
name: "oracle",
prompt: "oracle prompt",
mode: "subagent",
}
const builtinMultimodalLookerConfig: AgentConfig = {
name: "multimodal-looker",
prompt: "multimodal prompt",
mode: "subagent",
}
const builtinAtlasConfig: AgentConfig = {
name: "atlas",
prompt: "atlas prompt",
mode: "all",
model: "openai/gpt-5.4",
}
const sisyphusJuniorConfig: AgentConfig = {
name: "Sisyphus-Junior",
prompt: "junior prompt",
mode: "all",
}
beforeEach(() => {
createBuiltinAgentsSpy = spyOn(agents, "createBuiltinAgents").mockResolvedValue({
sisyphus: builtinSisyphusConfig,
oracle: builtinOracleConfig,
"multimodal-looker": builtinMultimodalLookerConfig,
atlas: builtinAtlasConfig,
})
createSisyphusJuniorAgentSpy = spyOn(
sisyphusJunior,
"createSisyphusJuniorAgentWithOverrides",
).mockReturnValue(sisyphusJuniorConfig)
discoverConfigSourceSkillsSpy = spyOn(
skillLoader,
"discoverConfigSourceSkills",
).mockResolvedValue([])
discoverUserClaudeSkillsSpy = spyOn(
skillLoader,
"discoverUserClaudeSkills",
).mockResolvedValue([])
discoverProjectClaudeSkillsSpy = spyOn(
skillLoader,
"discoverProjectClaudeSkills",
).mockResolvedValue([])
discoverOpencodeGlobalSkillsSpy = spyOn(
skillLoader,
"discoverOpencodeGlobalSkills",
).mockResolvedValue([])
discoverOpencodeProjectSkillsSpy = spyOn(
skillLoader,
"discoverOpencodeProjectSkills",
).mockResolvedValue([])
discoverProjectAgentsSkillsSpy = spyOn(
skillLoader,
"discoverProjectAgentsSkills",
).mockResolvedValue([])
discoverGlobalAgentsSkillsSpy = spyOn(
skillLoader,
"discoverGlobalAgentsSkills",
).mockResolvedValue([])
loadUserAgentsSpy = spyOn(agentLoader, "loadUserAgents").mockReturnValue({})
loadProjectAgentsSpy = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({})
migrateAgentConfigSpy = spyOn(shared, "migrateAgentConfig").mockImplementation(
(config: Record<string, unknown>) => config,
)
logSpy = spyOn(shared, "log").mockImplementation(() => {})
})
afterEach(() => {
createBuiltinAgentsSpy.mockRestore()
createSisyphusJuniorAgentSpy.mockRestore()
discoverConfigSourceSkillsSpy.mockRestore()
discoverUserClaudeSkillsSpy.mockRestore()
discoverProjectClaudeSkillsSpy.mockRestore()
discoverOpencodeGlobalSkillsSpy.mockRestore()
discoverOpencodeProjectSkillsSpy.mockRestore()
discoverProjectAgentsSkillsSpy.mockRestore()
discoverGlobalAgentsSkillsSpy.mockRestore()
loadUserAgentsSpy.mockRestore()
loadProjectAgentsSpy.mockRestore()
migrateAgentConfigSpy.mockRestore()
logSpy.mockRestore()
})
test("registered agent keys are HTTP-header-safe (no parentheses) for UI selector compatibility", async () => {
// given builtin agents are registered via applyAgentConfig
// when applyAgentConfig runs
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then every registered agent key must be HTTP-header-safe (no parentheses)
// Parentheses in agent names cause HTTP header validation errors in
// x-opencode-agent-name and prevent the agents from showing in the OpenCode UI.
for (const key of Object.keys(result)) {
expect(key).not.toMatch(/[()]/)
}
})
test("filters user agents whose key matches the builtin display-name alias", async () => {
// given
loadUserAgentsSpy.mockReturnValue({
[BUILTIN_SISYPHUS_DISPLAY_NAME]: {
name: BUILTIN_SISYPHUS_DISPLAY_NAME,
prompt: "user alias prompt",
mode: "subagent",
},
})
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result[BUILTIN_SISYPHUS_DISPLAY_NAME]).toEqual(builtinSisyphusConfig)
})
test("filters user agents whose key differs from a builtin key only by case", async () => {
// given
loadUserAgentsSpy.mockReturnValue({
SiSyPhUs: {
name: "SiSyPhUs",
prompt: "mixed-case prompt",
mode: "subagent",
},
})
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result[BUILTIN_SISYPHUS_DISPLAY_NAME]).toEqual(builtinSisyphusConfig)
expect(result.SiSyPhUs).toBeUndefined()
})
test("filters plugin agents whose key matches the builtin display-name alias", async () => {
// given
const pluginComponents = createPluginComponents()
pluginComponents.agents = {
[BUILTIN_SISYPHUS_DISPLAY_NAME]: {
name: BUILTIN_SISYPHUS_DISPLAY_NAME,
prompt: "plugin alias prompt",
mode: "subagent",
},
}
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents,
})
// then
expect(result[BUILTIN_SISYPHUS_DISPLAY_NAME]).toEqual(builtinSisyphusConfig)
})
describe("#given protected builtin agents use hyphenated names", () => {
describe("#when a user agent uses the underscored multimodal looker alias", () => {
test("filters the override", async () => {
// given
loadUserAgentsSpy.mockReturnValue({
multimodal_looker: {
name: "multimodal_looker",
prompt: "user multimodal alias prompt",
mode: "subagent",
},
})
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result[BUILTIN_MULTIMODAL_LOOKER_DISPLAY_NAME]).toEqual(builtinMultimodalLookerConfig)
expect(result.multimodal_looker).toBeUndefined()
})
})
describe("#when a user agent uses the underscored sisyphus junior alias", () => {
test("filters the override", async () => {
// given
loadUserAgentsSpy.mockReturnValue({
sisyphus_junior: {
name: "sisyphus_junior",
prompt: "user junior alias prompt",
mode: "subagent",
},
})
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result[BUILTIN_SISYPHUS_JUNIOR_DISPLAY_NAME]).toEqual(sisyphusJuniorConfig)
expect(result.sisyphus_junior).toBeUndefined()
})
})
})
test("passes the resolved Atlas model to Sisyphus-Junior as its fallback default", async () => {
// given
// when
await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(createSisyphusJuniorAgentSpy).toHaveBeenCalledWith(undefined, "openai/gpt-5.4", false)
})
test("defaults mode to subagent for configAgent entries missing mode", async () => {
// given
const config = createBaseConfig()
;(config as Record<string, unknown>).agent = {
"custom-reviewer": {
name: "custom-reviewer",
prompt: "Review code for security issues",
description: "Custom code reviewer",
},
}
// when
const result = await applyAgentConfig({
config,
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
const customAgent = result["custom-reviewer"] as Record<string, unknown>
expect(customAgent).toBeDefined()
expect(customAgent.mode).toBe("subagent")
})
test("preserves explicit mode on configAgent entries", async () => {
// given
const config = createBaseConfig()
;(config as Record<string, unknown>).agent = {
"custom-primary": {
name: "custom-primary",
prompt: "Primary agent",
mode: "primary",
},
}
// when
const result = await applyAgentConfig({
config,
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
const customAgent = result["custom-primary"] as Record<string, unknown>
expect(customAgent).toBeDefined()
expect(customAgent.mode).toBe("primary")
})
test("defaults mode to subagent for plugin agents missing mode", async () => {
// given
const pluginComponents = createPluginComponents()
pluginComponents.agents = {
"plugin-worker": {
name: "plugin-worker",
prompt: "Do work",
description: "Plugin worker agent",
} as Record<string, unknown>,
}
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents,
})
// then
const pluginAgent = result["plugin-worker"] as Record<string, unknown>
expect(pluginAgent).toBeDefined()
expect(pluginAgent.mode).toBe("subagent")
})
test("includes project and global .agents skills in builtin agent awareness", async () => {
// given
const projectAgentsSkill = {
name: "project-agent-skill",
definition: {
name: "project-agent-skill",
description: "Project agent skill",
template: "template",
},
scope: "project",
} satisfies LoadedSkill
const globalAgentsSkill = {
name: "global-agent-skill",
definition: {
name: "global-agent-skill",
description: "Global agent skill",
template: "template",
},
scope: "user",
} satisfies LoadedSkill
discoverProjectAgentsSkillsSpy.mockResolvedValue([projectAgentsSkill])
discoverGlobalAgentsSkillsSpy.mockResolvedValue([globalAgentsSkill])
// when
await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
const discoveredSkills = createBuiltinAgentsSpy.mock.calls[0]?.[6]
expect(discoveredSkills).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "project-agent-skill" }),
expect.objectContaining({ name: "global-agent-skill" }),
]),
)
})
})