Skip to content

Commit 6df3fd5

Browse files
committed
fix(gateway): list commands from gateway plugin registry
1 parent 7c31525 commit 6df3fd5

5 files changed

Lines changed: 435 additions & 52 deletions

File tree

src/gateway/server-methods/commands.test.ts

Lines changed: 186 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ const mockChatCommands: ChatCommandDefinition[] = [
7272
];
7373

7474
const mockPluginSpecs = [{ name: "tts", description: "Text to speech", acceptsArgs: false }];
75+
const runtimeMocks = vi.hoisted(() => ({
76+
gatewayRegistry: null as null | {
77+
commands: Array<{
78+
pluginId: string;
79+
command: {
80+
name: string;
81+
description: string;
82+
acceptsArgs?: boolean;
83+
nativeNames?: Record<string, string>;
84+
channels?: string[];
85+
};
86+
}>;
87+
},
88+
}));
7589

7690
vi.mock("../../auto-reply/commands-registry.js", () => ({
7791
listChatCommandsForConfig: vi.fn(() => mockChatCommands),
@@ -80,15 +94,66 @@ vi.mock("../../skills/discovery/chat-commands.js", () => ({
8094
listSkillCommandsForAgents: vi.fn(() => mockSkillCommands),
8195
}));
8296
vi.mock("../../plugins/command-specs.js", () => ({
83-
getPluginCommandSpecs: vi.fn((provider?: string) => {
97+
getPluginCommandEntrySpecs: vi.fn((provider?: string) => {
8498
if (provider === "whatsapp") {
85-
return [];
99+
return [{ name: "tts", description: "Text to speech", acceptsArgs: false }];
86100
}
87101
if (provider === "discord") {
88-
return [{ name: "discord_tts", description: "Text to speech", acceptsArgs: false }];
102+
return [
103+
{
104+
name: "tts",
105+
nativeName: "discord_tts",
106+
description: "Text to speech",
107+
acceptsArgs: false,
108+
},
109+
];
89110
}
90-
return mockPluginSpecs;
111+
return mockPluginSpecs.map((entry) => ({
112+
name: entry.name,
113+
nativeName: entry.name,
114+
description: entry.description,
115+
acceptsArgs: entry.acceptsArgs,
116+
}));
91117
}),
118+
getPluginCommandEntrySpecsFromRegistrations: vi.fn(
119+
(
120+
commands: Array<{
121+
command: {
122+
name: string;
123+
description: string;
124+
acceptsArgs?: boolean;
125+
nativeNames?: Record<string, string>;
126+
channels?: string[];
127+
};
128+
}>,
129+
provider?: string,
130+
) => {
131+
return commands
132+
.filter(
133+
(entry) =>
134+
!provider || !entry.command.channels || entry.command.channels.includes(provider),
135+
)
136+
.map((entry) => {
137+
const spec: {
138+
name: string;
139+
nativeName?: string;
140+
description: string;
141+
acceptsArgs: boolean;
142+
} = {
143+
name: entry.command.name.trim(),
144+
description: entry.command.description.trim(),
145+
acceptsArgs: entry.command.acceptsArgs ?? false,
146+
};
147+
if (provider !== "whatsapp") {
148+
spec.nativeName =
149+
(provider ? entry.command.nativeNames?.[provider] : undefined) ??
150+
entry.command.nativeNames?.default ??
151+
entry.command.name.trim();
152+
}
153+
return spec;
154+
});
155+
},
156+
),
92157
}));
93158
vi.mock("../../plugins/commands.js", () => ({
94159
listPluginCommands: vi.fn(() => [
@@ -100,6 +165,9 @@ vi.mock("../../plugins/commands.js", () => ({
100165
},
101166
]),
102167
}));
168+
vi.mock("../../plugins/runtime.js", () => ({
169+
getActivePluginGatewayCommandRegistry: vi.fn(() => runtimeMocks.gatewayRegistry),
170+
}));
103171
vi.mock("../../config/config.js", () => ({
104172
getRuntimeConfig: vi.fn(() => ({})),
105173
}));
@@ -202,6 +270,7 @@ function collectBuiltinNames(commands: readonly { name: string; source: string }
202270

203271
describe("commands.list handler", () => {
204272
beforeEach(() => {
273+
runtimeMocks.gatewayRegistry = null;
205274
vi.clearAllMocks();
206275
});
207276

@@ -386,6 +455,119 @@ describe("commands.list handler", () => {
386455
expect(plugin?.textAliases).toEqual(["/tts"]);
387456
});
388457

458+
it("reads plugin commands from the gateway registry before the global command table", () => {
459+
runtimeMocks.gatewayRegistry = {
460+
commands: [
461+
{
462+
pluginId: "phone-control",
463+
command: {
464+
name: " phone ",
465+
description: " Control paired phones ",
466+
acceptsArgs: true,
467+
},
468+
},
469+
],
470+
};
471+
472+
const { payload } = callHandler();
473+
const { commands } = payload as {
474+
commands: Array<{
475+
name: string;
476+
description: string;
477+
source: string;
478+
textAliases?: string[];
479+
acceptsArgs?: boolean;
480+
}>;
481+
};
482+
const phone = commands.find((c) => c.source === "plugin");
483+
484+
expect(phone?.name).toBe("phone");
485+
expect(phone?.description).toBe("Control paired phones");
486+
expect(phone?.textAliases).toEqual(["/phone"]);
487+
expect(phone?.acceptsArgs).toBe(true);
488+
expect(commands.find((c) => c.source === "plugin" && c.name === "tts")).toBeUndefined();
489+
});
490+
491+
it("keeps provider-filtered native plugin names paired with their text aliases", () => {
492+
runtimeMocks.gatewayRegistry = {
493+
commands: [
494+
{
495+
pluginId: "android-only",
496+
command: {
497+
name: "android_only",
498+
description: "Android-only command",
499+
channels: ["android"],
500+
},
501+
},
502+
{
503+
pluginId: "phone-control",
504+
command: {
505+
name: "phone",
506+
description: "Control paired phones",
507+
acceptsArgs: true,
508+
channels: ["discord"],
509+
nativeNames: { discord: "discord_phone" },
510+
},
511+
},
512+
],
513+
};
514+
515+
const { payload } = callHandler({ provider: "discord" });
516+
const { commands } = payload as {
517+
commands: Array<{
518+
name: string;
519+
source: string;
520+
textAliases?: string[];
521+
nativeName?: string;
522+
}>;
523+
};
524+
const plugin = commands.find((c) => c.source === "plugin");
525+
526+
expect(plugin?.name).toBe("discord_phone");
527+
expect(plugin?.nativeName).toBe("discord_phone");
528+
expect(plugin?.textAliases).toEqual(["/phone"]);
529+
expect(
530+
commands.find((c) => c.source === "plugin" && c.name === "android_only"),
531+
).toBeUndefined();
532+
});
533+
534+
it("filters provider-incompatible plugin commands from the text surface", () => {
535+
runtimeMocks.gatewayRegistry = {
536+
commands: [
537+
{
538+
pluginId: "android-only",
539+
command: {
540+
name: "android_only",
541+
description: "Android-only command",
542+
channels: ["android"],
543+
},
544+
},
545+
{
546+
pluginId: "phone-control",
547+
command: {
548+
name: "phone",
549+
description: "Control paired phones",
550+
channels: ["discord"],
551+
},
552+
},
553+
],
554+
};
555+
556+
const { payload } = callHandler({ provider: "discord", scope: "text" });
557+
const { commands } = payload as {
558+
commands: Array<{
559+
name: string;
560+
source: string;
561+
textAliases?: string[];
562+
}>;
563+
};
564+
565+
expect(
566+
commands.find((c) => c.source === "plugin" && c.name === "android_only"),
567+
).toBeUndefined();
568+
expect(commands.find((c) => c.source === "plugin")?.textAliases).toEqual(["/phone"]);
569+
});
570+
389571
it("returns provider-specific plugin command names", () => {
390572
const { payload } = callHandler({ provider: "discord" });
391573
const { commands } = payload as { commands: Array<{ name: string; source: string }> };

src/gateway/server-methods/commands.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ import type {
2929
} from "../../auto-reply/commands-registry.types.js";
3030
import { getChannelPlugin } from "../../channels/plugins/index.js";
3131
import type { OpenClawConfig } from "../../config/types.openclaw.js";
32-
import { getPluginCommandSpecs } from "../../plugins/command-specs.js";
33-
import { listPluginCommands } from "../../plugins/commands.js";
32+
import {
33+
getPluginCommandEntrySpecs,
34+
getPluginCommandEntrySpecsFromRegistrations,
35+
} from "../../plugins/command-specs.js";
36+
import { getActivePluginGatewayCommandRegistry } from "../../plugins/runtime.js";
3437
import { listSkillCommandsForAgents } from "../../skills/discovery/chat-commands.js";
3538
import { resolveAgentIdOrRespondError } from "./agent-id-shared.js";
3639
import type { GatewayRequestHandlers } from "./types.js";
@@ -163,24 +166,28 @@ function buildPluginCommandEntries(params: {
163166
nameSurface: CommandNameSurface;
164167
cfg: OpenClawConfig;
165168
}): CommandEntry[] {
166-
const pluginTextSpecs = listPluginCommands();
167-
const pluginNativeSpecs = getPluginCommandSpecs(params.provider, { config: params.cfg });
169+
const gatewayRegistry = getActivePluginGatewayCommandRegistry();
170+
const pluginSpecs = gatewayRegistry
171+
? getPluginCommandEntrySpecsFromRegistrations(gatewayRegistry.commands, params.provider, {
172+
config: params.cfg,
173+
})
174+
: getPluginCommandEntrySpecs(params.provider, { config: params.cfg });
168175
const entries: CommandEntry[] = [];
169176

170-
for (const [index, textSpec] of pluginTextSpecs.entries()) {
171-
const nativeSpec = pluginNativeSpecs[index];
172-
const nativeName = nativeSpec?.name;
177+
for (const spec of pluginSpecs) {
173178
entries.push({
174179
name: clampString(
175-
params.nameSurface === "text" ? textSpec.name : (nativeName ?? textSpec.name),
180+
params.nameSurface === "text" ? spec.name : (spec.nativeName ?? spec.name),
176181
COMMAND_NAME_MAX_LENGTH,
177182
),
178-
...(nativeName ? { nativeName: clampString(nativeName, COMMAND_NAME_MAX_LENGTH) } : {}),
179-
textAliases: [`/${clampString(textSpec.name, COMMAND_NAME_MAX_LENGTH)}`],
180-
description: clampDescription(textSpec.description),
183+
...(spec.nativeName
184+
? { nativeName: clampString(spec.nativeName, COMMAND_NAME_MAX_LENGTH) }
185+
: {}),
186+
textAliases: [`/${clampString(spec.name, COMMAND_NAME_MAX_LENGTH)}`],
187+
description: clampDescription(spec.description),
181188
source: "plugin",
182189
scope: "both",
183-
acceptsArgs: textSpec.acceptsArgs,
190+
acceptsArgs: spec.acceptsArgs,
184191
});
185192
}
186193

0 commit comments

Comments
 (0)