fix: preserve commands.list metadata (#64147)

Merged via squash.

Reviewed-by: @frankekn
This commit is contained in:
Frank Yang
2026-04-10 15:35:05 +08:00
committed by GitHub
parent c919cc2cef
commit 360955a7c8
5 changed files with 58 additions and 28 deletions

View File

@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
- UI/compaction: keep the compaction indicator in a retry-pending state until the run actually finishes, so the UI does not show `Context compacted` before compaction actually finishes. (#55132) Thanks @mpz4life.
- Cron/tool schemas: keep cron tool schemas strict-model-friendly while still preserving `failureAlert=false`, nullable `agentId`/`sessionKey`, and flattened add/update recovery for the newly exposed cron job fields. (#55043) Thanks @brunolorente.
- Git metadata: read commit ids from packed refs as well as loose refs so version and status metadata stay accurate after repository maintenance. (#63943)
- Gateway: keep `commands.list` skill entries categorized under tools and include provider-aware plugin `nativeName` metadata even when `scope=text`, so remote clients can group skills correctly and map text-surface plugin commands back to native aliases.
## 2026.4.9

View File

@@ -100,6 +100,9 @@ describe("commands registry", () => {
{ skillCommands },
);
expect(commands.find((spec) => spec.nativeName === "demo_skill")).toBeTruthy();
expect(commands.find((spec) => spec.nativeName === "demo_skill")).toMatchObject({
category: "tools",
});
const native = listNativeCommandSpecsForConfig(
{ commands: { config: false, plugins: false, debug: false, native: true } },

View File

@@ -88,6 +88,7 @@ function buildSkillCommandDefinitions(skillCommands?: SkillCommandSpec[]): ChatC
acceptsArgs: true,
argsParsing: "none",
scope: "both",
category: "tools",
}));
}

View File

@@ -299,12 +299,35 @@ describe("commands.list handler", () => {
it("keeps plugin text commands visible for scope=text even without native provider support", () => {
const { payload } = callHandler({ provider: "whatsapp", scope: "text" });
const { commands } = payload as {
commands: Array<{ name: string; source: string; textAliases?: string[] }>;
commands: Array<{
name: string;
source: string;
textAliases?: string[];
nativeName?: string;
}>;
};
expect(commands.find((c) => c.source === "plugin")).toMatchObject({
name: "tts",
textAliases: ["/tts"],
});
expect(commands.find((c) => c.source === "plugin")?.nativeName).toBeUndefined();
});
it("keeps plugin text names while exposing provider-native aliases for scope=text", () => {
const { payload } = callHandler({ provider: "discord", scope: "text" });
const { commands } = payload as {
commands: Array<{
name: string;
source: string;
textAliases?: string[];
nativeName?: string;
}>;
};
expect(commands.find((c) => c.source === "plugin")).toMatchObject({
name: "tts",
nativeName: "discord_tts",
textAliases: ["/tts"],
});
});
it("returns provider-specific plugin command names", () => {

View File

@@ -120,6 +120,34 @@ function mapCommand(
};
}
function buildPluginCommandEntries(params: {
provider?: string;
nameSurface: CommandNameSurface;
}): CommandEntry[] {
const pluginTextSpecs = listPluginCommands();
const pluginNativeSpecs = getPluginCommandSpecs(params.provider);
const entries: CommandEntry[] = [];
for (const [index, textSpec] of pluginTextSpecs.entries()) {
const nativeSpec = pluginNativeSpecs[index];
const nativeName = nativeSpec?.name;
entries.push({
name: params.nameSurface === "text" ? textSpec.name : (nativeName ?? textSpec.name),
...(nativeName ? { nativeName } : {}),
textAliases: [`/${textSpec.name}`],
description: textSpec.description,
source: "plugin",
scope: "both",
acceptsArgs: textSpec.acceptsArgs,
});
}
if (params.nameSurface === "native") {
return entries.filter((entry) => entry.nativeName);
}
return entries;
}
export function buildCommandsListResult(params: {
cfg: ReturnType<typeof loadConfig>;
agentId: string;
@@ -153,33 +181,7 @@ export function buildCommandsListResult(params: {
);
}
if (nameSurface === "text") {
for (const spec of listPluginCommands()) {
commands.push({
name: spec.name,
textAliases: [`/${spec.name}`],
description: spec.description,
source: "plugin",
scope: "both",
acceptsArgs: spec.acceptsArgs,
});
}
} else {
const pluginTextSpecs = listPluginCommands();
const pluginSpecs = getPluginCommandSpecs(provider);
for (const [index, spec] of pluginSpecs.entries()) {
const textName = pluginTextSpecs[index]?.name ?? spec.name;
commands.push({
name: spec.name,
nativeName: spec.name,
textAliases: [`/${textName}`],
description: spec.description,
source: "plugin",
scope: "both",
acceptsArgs: spec.acceptsArgs,
});
}
}
commands.push(...buildPluginCommandEntries({ provider, nameSurface }));
return { commands };
}