Files
oh-my-opencode/src/plugin-handlers/command-config-handler.test.ts
T
YeonGyu-Kim 47283f9238 fix(agents): remove ZWSP prefixes from config.agent keys (#3238)
Agent names in the config.agent object (which becomes the /agent API
response) contained invisible Zero-Width Space (U+200B) characters
baked in by getAgentListDisplayName(). These ZWSP prefixes were used
for TUI sort ordering, but they leaked into the public API surface.

Impact: any prompt_async consumer that discovered agent names via the
/agent endpoint and passed them back to prompt_async without manual
ZWSP stripping got silent message drops — the agent name didn't match.
hy-pony's feishu-bridge integration went dark after upgrading to 3.16.0
with no error, no warning, and no indication that invisible Unicode
characters in agent names were the cause.

Fix: switch all four callsites from getAgentListDisplayName() (which
prepends \u200B×N) to getAgentDisplayName() (clean names):

- agent-key-remapper.ts: config keys → display names (was the primary
  injection point)
- agent-priority-order.ts: CORE_AGENT_ORDER lookup (must agree with
  the keys emitted by the remapper)
- command-config-handler.ts: command agent field normalization
- tool-config-handler.ts: agent config lookup (simplified fallback
  chain since the primary lookup is now clean)

Sort ordering is preserved by:
1. JS object insertion order from reorderAgentsByPriority()
2. The injected `order` field (1-4) added by injectOrderField()

getAgentListDisplayName() is marked @deprecated with a link to #3238.
AGENT_LIST_SORT_PREFIXES and stripAgentListSortPrefix() are kept for
any internal callers that strip prefixes from legacy data.

Closes #3238
2026-04-08 22:51:59 +09:00

153 lines
5.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import * as builtinCommands from "../features/builtin-commands";
import * as commandLoader from "../features/claude-code-command-loader";
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,
getAgentDisplayName,
} from "../shared/agent-display-names";
function createPluginComponents(): PluginComponents {
return {
commands: {},
skills: {},
agents: {},
mcpServers: {},
hooksConfigs: [],
plugins: [],
errors: [],
};
}
function createPluginConfig(): OhMyOpenCodeConfig {
return {};
}
describe("applyCommandConfig", () => {
let loadBuiltinCommandsSpy: ReturnType<typeof spyOn>;
let loadUserCommandsSpy: ReturnType<typeof spyOn>;
let loadProjectCommandsSpy: ReturnType<typeof spyOn>;
let loadOpencodeGlobalCommandsSpy: ReturnType<typeof spyOn>;
let loadOpencodeProjectCommandsSpy: ReturnType<typeof spyOn>;
let discoverConfigSourceSkillsSpy: ReturnType<typeof spyOn>;
let loadUserSkillsSpy: ReturnType<typeof spyOn>;
let loadProjectSkillsSpy: ReturnType<typeof spyOn>;
let loadOpencodeGlobalSkillsSpy: ReturnType<typeof spyOn>;
let loadOpencodeProjectSkillsSpy: ReturnType<typeof spyOn>;
let loadProjectAgentsSkillsSpy: ReturnType<typeof spyOn>;
let loadGlobalAgentsSkillsSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
loadBuiltinCommandsSpy = spyOn(builtinCommands, "loadBuiltinCommands").mockReturnValue({});
loadUserCommandsSpy = spyOn(commandLoader, "loadUserCommands").mockResolvedValue({});
loadProjectCommandsSpy = spyOn(commandLoader, "loadProjectCommands").mockResolvedValue({});
loadOpencodeGlobalCommandsSpy = spyOn(commandLoader, "loadOpencodeGlobalCommands").mockResolvedValue({});
loadOpencodeProjectCommandsSpy = spyOn(commandLoader, "loadOpencodeProjectCommands").mockResolvedValue({});
discoverConfigSourceSkillsSpy = spyOn(skillLoader, "discoverConfigSourceSkills").mockResolvedValue([]);
loadUserSkillsSpy = spyOn(skillLoader, "loadUserSkills").mockResolvedValue({});
loadProjectSkillsSpy = spyOn(skillLoader, "loadProjectSkills").mockResolvedValue({});
loadOpencodeGlobalSkillsSpy = spyOn(skillLoader, "loadOpencodeGlobalSkills").mockResolvedValue({});
loadOpencodeProjectSkillsSpy = spyOn(skillLoader, "loadOpencodeProjectSkills").mockResolvedValue({});
loadProjectAgentsSkillsSpy = spyOn(skillLoader, "loadProjectAgentsSkills").mockResolvedValue({});
loadGlobalAgentsSkillsSpy = spyOn(skillLoader, "loadGlobalAgentsSkills").mockResolvedValue({});
});
afterEach(() => {
loadBuiltinCommandsSpy.mockRestore();
loadUserCommandsSpy.mockRestore();
loadProjectCommandsSpy.mockRestore();
loadOpencodeGlobalCommandsSpy.mockRestore();
loadOpencodeProjectCommandsSpy.mockRestore();
discoverConfigSourceSkillsSpy.mockRestore();
loadUserSkillsSpy.mockRestore();
loadProjectSkillsSpy.mockRestore();
loadOpencodeGlobalSkillsSpy.mockRestore();
loadOpencodeProjectSkillsSpy.mockRestore();
loadProjectAgentsSkillsSpy.mockRestore();
loadGlobalAgentsSkillsSpy.mockRestore();
});
test("includes .agents skills in command config", async () => {
// given
loadProjectAgentsSkillsSpy.mockResolvedValue({
"agents-project-skill": {
description: "(project - Skill) Agents project skill",
template: "template",
},
});
loadGlobalAgentsSkillsSpy.mockResolvedValue({
"agents-global-skill": {
description: "(user - Skill) Agents global skill",
template: "template",
},
});
const config: Record<string, unknown> = { command: {} };
// when
await applyCommandConfig({
config,
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
});
// then
const commandConfig = config.command as Record<string, { description?: string }>;
expect(commandConfig["agents-project-skill"]?.description).toContain("Agents project skill");
expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill");
});
test("normalizes Atlas command agents to the exported list key used by opencode command routing", async () => {
// given
loadBuiltinCommandsSpy.mockReturnValue({
"start-work": {
name: "start-work",
description: "(builtin) Start work",
template: "template",
agent: "atlas",
},
});
const config: Record<string, unknown> = { command: {} };
// when
await applyCommandConfig({
config,
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
});
// then
const commandConfig = config.command as Record<string, { agent?: string }>;
expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas"));
});
test("normalizes legacy display-name command agents to the exported list key", async () => {
// given
loadBuiltinCommandsSpy.mockReturnValue({
"start-work": {
name: "start-work",
description: "(builtin) Start work",
template: "template",
agent: getAgentDisplayName("atlas"),
},
});
const config: Record<string, unknown> = { command: {} };
// when
await applyCommandConfig({
config,
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
});
// then
const commandConfig = config.command as Record<string, { agent?: string }>;
expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas"));
});
});