47283f9238
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
153 lines
5.8 KiB
TypeScript
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"));
|
|
});
|
|
});
|