Plugin SDK: require unified message discovery

This commit is contained in:
Gustavo Madeira Santana
2026-03-18 03:02:02 +00:00
parent 870f260772
commit 682f4d1ca3
31 changed files with 155 additions and 301 deletions

View File

@@ -46,7 +46,7 @@ vi.mock("./probe.js", () => ({
}));
describe("bluebubblesMessageActions", () => {
const listActions = bluebubblesMessageActions.listActions!;
const describeMessageTool = bluebubblesMessageActions.describeMessageTool!;
const supportsAction = bluebubblesMessageActions.supportsAction!;
const extractToolSend = bluebubblesMessageActions.extractToolSend!;
const handleAction = bluebubblesMessageActions.handleAction!;
@@ -74,12 +74,12 @@ describe("bluebubblesMessageActions", () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
describe("listActions", () => {
describe("describeMessageTool", () => {
it("returns empty array when account is not enabled", () => {
const cfg: OpenClawConfig = {
channels: { bluebubbles: { enabled: false } },
};
const actions = listActions({ cfg });
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toEqual([]);
});
@@ -87,7 +87,7 @@ describe("bluebubblesMessageActions", () => {
const cfg: OpenClawConfig = {
channels: { bluebubbles: { enabled: true } },
};
const actions = listActions({ cfg });
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toEqual([]);
});
@@ -101,7 +101,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
const actions = listActions({ cfg });
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toContain("react");
});
@@ -116,7 +116,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
const actions = listActions({ cfg });
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).not.toContain("react");
// Other actions should still be present
expect(actions).toContain("edit");
@@ -134,7 +134,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
const actions = listActions({ cfg });
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toContain("sendAttachment");
expect(actions).not.toContain("react");
expect(actions).not.toContain("reply");

View File

@@ -67,10 +67,10 @@ const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
]);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg, currentChannelId }) => {
describeMessageTool: ({ cfg, currentChannelId }) => {
const account = resolveBlueBubblesAccount({ cfg: cfg });
if (!account.enabled || !account.configured) {
return [];
return null;
}
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>();
@@ -107,7 +107,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
}
}
}
return Array.from(actions);
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),

View File

@@ -73,7 +73,7 @@ export async function fetchBlueBubblesServerInfo(params: {
}
/**
* Get cached server info synchronously (for use in listActions).
* Get cached server info synchronously (for use in describeMessageTool).
* Returns null if not cached or expired.
*/
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {

View File

@@ -51,10 +51,10 @@ function resolveAppUserNames(account: { config: { botUser?: string | null } }) {
}
export const googlechatMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
describeMessageTool: ({ cfg }) => {
const accounts = listEnabledAccounts(cfg);
if (accounts.length === 0) {
return [];
return null;
}
const actions = new Set<ChannelMessageActionName>([]);
actions.add("send");
@@ -62,7 +62,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
actions.add("react");
actions.add("reactions");
}
return Array.from(actions);
return { actions: Array.from(actions) };
},
extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage");

View File

@@ -98,7 +98,7 @@ const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver<ResolvedGoogleC
});
const googlechatActions: ChannelMessageActionAdapter = {
listActions: (ctx) => googlechatMessageActions.listActions?.(ctx) ?? [],
describeMessageTool: (ctx) => googlechatMessageActions.describeMessageTool?.(ctx) ?? null,
extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null,
handleAction: async (ctx) => {
if (!googlechatMessageActions.handleAction) {

View File

@@ -12,10 +12,10 @@ import { handleMatrixAction } from "./tool-actions.js";
import type { CoreConfig } from "./types.js";
export const matrixMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
describeMessageTool: ({ cfg }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
if (!account.enabled || !account.configured) {
return [];
return null;
}
const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions);
const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
@@ -39,7 +39,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
if (gate("channelInfo")) {
actions.add("channel-info");
}
return Array.from(actions);
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => action !== "poll",
extractToolSend: ({ args }): ChannelToolSend | null => {

View File

@@ -173,7 +173,7 @@ describe("mattermostPlugin", () => {
expect(actions).toContain("send");
});
it("respects per-account actions.reactions in listActions", () => {
it("respects per-account actions.reactions in message discovery", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {

View File

@@ -36,7 +36,8 @@ import { signalSetupAdapter } from "./setup-core.js";
import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js";
const signalMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [],
describeMessageTool: (ctx) =>
getSignalRuntime().channel.signal.messageActions?.describeMessageTool?.(ctx) ?? null,
supportsAction: (ctx) =>
getSignalRuntime().channel.signal.messageActions?.supportsAction?.(ctx) ?? false,
handleAction: async (ctx) => {

View File

@@ -68,7 +68,7 @@ export const twitchMessageActions: ChannelMessageActionAdapter = {
/**
* List available actions for this channel.
*/
listActions: () => [...TWITCH_ACTIONS],
describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
/**
* Check if an action is supported.

View File

@@ -108,9 +108,9 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
listGroups: async (params) => listWhatsAppDirectoryGroupsFromConfig(params),
},
actions: {
listActions: ({ cfg }) => {
describeMessageTool: ({ cfg }) => {
if (!cfg.channels?.whatsapp) {
return [];
return null;
}
const gate = createActionGate(cfg.channels.whatsapp.actions);
const actions = new Set<ChannelMessageActionName>();
@@ -120,7 +120,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
if (gate("polls")) {
actions.add("poll");
}
return Array.from(actions);
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => action === "react",
handleAction: async ({ action, params, cfg, accountId }) => {

View File

@@ -21,15 +21,14 @@ function listEnabledAccounts(cfg: OpenClawConfig) {
}
export const zaloMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
describeMessageTool: ({ cfg }) => {
const accounts = listEnabledAccounts(cfg);
if (accounts.length === 0) {
return [];
return null;
}
const actions = new Set<ChannelMessageActionName>(["send"]);
return Array.from(actions);
return { actions: Array.from(actions), capabilities: [] };
},
getCapabilities: () => [],
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") {

View File

@@ -131,9 +131,10 @@ describe("zalouser channel policies", () => {
it("handles react action", async () => {
const actions = zalouserPlugin.actions;
expect(actions?.listActions?.({ cfg: { channels: { zalouser: { enabled: true } } } })).toEqual([
"react",
]);
expect(
actions?.describeMessageTool?.({ cfg: { channels: { zalouser: { enabled: true } } } })
?.actions,
).toEqual(["react"]);
const result = await actions?.handleAction?.({
channel: "zalouser",
action: "react",

View File

@@ -218,14 +218,14 @@ function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
}
const zalouserMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
describeMessageTool: ({ cfg }) => {
const accounts = listZalouserAccountIds(cfg)
.map((accountId) => resolveZalouserAccountSync({ cfg, accountId }))
.filter((account) => account.enabled);
if (accounts.length === 0) {
return [];
return null;
}
return ["react"];
return { actions: ["react"] };
},
supportsAction: ({ action }) => action === "react",
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {

View File

@@ -29,7 +29,7 @@ describe("channel tools", () => {
resolveAccount: () => ({}),
},
actions: {
listActions: () => {
describeMessageTool: () => {
throw new Error("boom");
},
},
@@ -70,7 +70,7 @@ describe("channel tools", () => {
resolveAccount: () => ({}),
},
actions: {
listActions: () => [],
describeMessageTool: () => ({ actions: [] }),
},
outbound: {
deliveryMode: "gateway",
@@ -102,7 +102,7 @@ describe("channel tools", () => {
resolveAccount: () => ({}),
},
actions: {
listActions: () => ["react"],
describeMessageTool: () => ({ actions: ["react"] }),
},
};
@@ -112,10 +112,7 @@ describe("channel tools", () => {
expect(listChannelSupportedActions({ cfg, channel: "tg" })).toEqual(["react"]);
});
it("uses unified message tool discovery when available", () => {
const listActions = vi.fn(() => {
throw new Error("legacy listActions should not run");
});
it("uses unified message tool discovery", () => {
const plugin: ChannelPlugin = {
id: "telegram",
meta: {
@@ -134,7 +131,6 @@ describe("channel tools", () => {
describeMessageTool: () => ({
actions: ["react"],
}),
listActions,
},
};
@@ -142,6 +138,5 @@ describe("channel tools", () => {
const cfg = {} as OpenClawConfig;
expect(listChannelSupportedActions({ cfg, channel: "telegram" })).toEqual(["react"]);
expect(listActions).not.toHaveBeenCalled();
});
});

View File

@@ -16,6 +16,12 @@ let createMessageTool: CreateMessageTool;
let setActivePluginRegistry: SetActivePluginRegistry;
let createTestRegistry: CreateTestRegistry;
type DescribeMessageTool = NonNullable<
NonNullable<ChannelPlugin["actions"]>["describeMessageTool"]
>;
type MessageToolDiscoveryContext = Parameters<DescribeMessageTool>[0];
type MessageToolSchema = NonNullable<ReturnType<DescribeMessageTool>>["schema"];
const mocks = vi.hoisted(() => ({
runMessageAction: vi.fn(),
loadConfig: vi.fn(() => ({})),
@@ -88,12 +94,11 @@ function createChannelPlugin(params: {
blurb: string;
aliases?: string[];
actions?: ChannelMessageActionName[];
listActions?: NonNullable<NonNullable<ChannelPlugin["actions"]>["listActions"]>;
capabilities?: readonly ChannelMessageCapability[];
toolSchema?: NonNullable<NonNullable<ChannelPlugin["actions"]>["getToolSchema"]>;
toolSchema?: MessageToolSchema | ((params: MessageToolDiscoveryContext) => MessageToolSchema);
describeMessageTool?: DescribeMessageTool;
messaging?: ChannelPlugin["messaging"];
}): ChannelPlugin {
const actionCapabilities = params.capabilities;
return {
id: params.id as ChannelPlugin["id"],
meta: {
@@ -111,15 +116,17 @@ function createChannelPlugin(params: {
},
...(params.messaging ? { messaging: params.messaging } : {}),
actions: {
listActions:
params.listActions ??
(() => {
return (params.actions ?? []) as never;
describeMessageTool:
params.describeMessageTool ??
((ctx) => {
const schema =
typeof params.toolSchema === "function" ? params.toolSchema(ctx) : params.toolSchema;
return {
actions: params.actions ?? [],
capabilities: params.capabilities,
...(schema ? { schema } : {}),
};
}),
...(actionCapabilities
? { getCapabilities: (_params: { cfg: unknown }) => actionCapabilities }
: {}),
...(params.toolSchema ? { getToolSchema: params.toolSchema } : {}),
},
};
}
@@ -398,30 +405,29 @@ describe("message tool schema scoping", () => {
label: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
listActions: ({ cfg }) => {
describeMessageTool: ({ cfg }) => {
const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } })
.channels?.telegram;
return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"];
},
capabilities: ["interactive", "buttons"],
toolSchema: ({ cfg }) => {
const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } })
.channels?.telegram;
return [
{
properties: {
buttons: createMessageToolButtonsSchema(),
return {
actions:
telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"],
capabilities: ["interactive", "buttons"],
schema: [
{
properties: {
buttons: createMessageToolButtonsSchema(),
},
},
},
...(telegramCfg?.actions?.poll === false
? []
: [
{
properties: createTelegramPollExtraToolSchemas(),
visibility: "all-configured" as const,
},
]),
];
...(telegramCfg?.actions?.poll === false
? []
: [
{
properties: createTelegramPollExtraToolSchemas(),
visibility: "all-configured" as const,
},
]),
],
};
},
});
@@ -458,13 +464,11 @@ describe("message tool schema scoping", () => {
label: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
actions: ["send"],
toolSchema: () => null,
describeMessageTool: ({ accountId }) => ({
actions: ["send"],
capabilities: accountId === "ops" ? ["interactive"] : [],
}),
});
scopedInteractivePlugin.actions = {
...scopedInteractivePlugin.actions,
getCapabilities: ({ accountId }) => (accountId === "ops" ? ["interactive"] : []),
};
setActivePluginRegistry(
createTestRegistry([
@@ -499,12 +503,10 @@ describe("message tool schema scoping", () => {
label: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
actions: ["send"],
describeMessageTool: ({ accountId }) => ({
actions: accountId === "ops" ? ["react"] : [],
}),
});
scopedOtherPlugin.actions = {
...scopedOtherPlugin.actions,
listActions: ({ accountId }) => (accountId === "ops" ? ["react"] : []),
};
setActivePluginRegistry(
createTestRegistry([
@@ -536,22 +538,14 @@ describe("message tool schema scoping", () => {
label: "Discord",
docsPath: "/channels/discord",
blurb: "Discord context plugin.",
listActions: (ctx) => {
seenContexts.push({ phase: "listActions", ...ctx });
return ["send", "react"];
},
toolSchema: (ctx) => {
seenContexts.push({ phase: "getToolSchema", ...ctx });
return null;
describeMessageTool: (ctx) => {
seenContexts.push({ phase: "describeMessageTool", ...ctx });
return {
actions: ["send", "react"],
capabilities: ["interactive"],
};
},
});
contextPlugin.actions = {
...contextPlugin.actions,
getCapabilities: (ctx) => {
seenContexts.push({ phase: "getCapabilities", ...ctx });
return ["interactive"];
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: contextPlugin }]),
@@ -595,7 +589,7 @@ describe("message tool description", () => {
label: "BlueBubbles",
docsPath: "/channels/bluebubbles",
blurb: "BlueBubbles test plugin.",
listActions: ({ currentChannelId }) => {
describeMessageTool: ({ currentChannelId }) => {
const all: ChannelMessageActionName[] = [
"react",
"renameGroup",
@@ -606,15 +600,17 @@ describe("message tool description", () => {
const lowered = currentChannelId?.toLowerCase() ?? "";
const isDmTarget =
lowered.includes("chat_guid:imessage;-;") || lowered.includes("chat_guid:sms;-;");
return isDmTarget
? all.filter(
(action) =>
action !== "renameGroup" &&
action !== "addParticipant" &&
action !== "removeParticipant" &&
action !== "leaveGroup",
)
: all;
return {
actions: isDmTarget
? all.filter(
(action) =>
action !== "renameGroup" &&
action !== "addParticipant" &&
action !== "removeParticipant" &&
action !== "leaveGroup",
)
: all,
};
},
messaging: {
normalizeTarget: (raw) => {

View File

@@ -1089,7 +1089,7 @@ describe("signalMessageActions", () => {
for (const testCase of cases) {
expect(
signalMessageActions.listActions?.({ cfg: testCase.cfg }) ?? [],
signalMessageActions.describeMessageTool?.({ cfg: testCase.cfg })?.actions ?? [],
testCase.name,
).toEqual(testCase.expected);
}

View File

@@ -74,14 +74,14 @@ async function mutateSignalReaction(params: {
}
export const signalMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
describeMessageTool: ({ cfg }) => {
const accounts = listEnabledSignalAccounts(cfg);
if (accounts.length === 0) {
return [];
return null;
}
const configuredAccounts = accounts.filter((account) => account.configured);
if (configuredAccounts.length === 0) {
return [];
return null;
}
const actions = new Set<ChannelMessageActionName>(["send"]);
@@ -93,7 +93,7 @@ export const signalMessageActions: ChannelMessageActionAdapter = {
actions.add("react");
}
return Array.from(actions);
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => action !== "send",

View File

@@ -43,16 +43,10 @@ function resolveContractMessageDiscovery(params: {
capabilities: [] as readonly ChannelMessageCapability[],
};
}
if (actions.describeMessageTool) {
const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null;
return {
actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [],
capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [],
};
}
const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null;
return {
actions: actions.listActions?.({ cfg: params.cfg }) ?? [],
capabilities: actions.getCapabilities?.({ cfg: params.cfg }) ?? [],
actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [],
capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [],
};
}
@@ -156,10 +150,7 @@ export function installChannelActionsContractSuite(params: {
}) {
it("exposes the base message actions contract", () => {
expect(params.plugin.actions).toBeDefined();
expect(
typeof params.plugin.actions?.describeMessageTool === "function" ||
typeof params.plugin.actions?.listActions === "function",
).toBe(true);
expect(typeof params.plugin.actions?.describeMessageTool).toBe("function");
});
for (const testCase of params.cases) {
@@ -223,10 +214,7 @@ export function installChannelSurfaceContractSuite(params: {
it(`exposes the ${surface} surface contract`, () => {
if (surface === "actions") {
expect(plugin.actions).toBeDefined();
expect(
typeof plugin.actions?.describeMessageTool === "function" ||
typeof plugin.actions?.listActions === "function",
).toBe(true);
expect(typeof plugin.actions?.describeMessageTool).toBe("function");
return;
}

View File

@@ -60,7 +60,7 @@ export function createMessageActionDiscoveryContext(
function logMessageActionError(params: {
pluginId: string;
operation: "describeMessageTool" | "getCapabilities" | "getToolSchema" | "listActions";
operation: "describeMessageTool";
error: unknown;
}) {
const message = params.error instanceof Error ? params.error.message : String(params.error);
@@ -75,24 +75,6 @@ function logMessageActionError(params: {
);
}
function runListActionsSafely(params: {
pluginId: string;
context: ChannelMessageActionDiscoveryContext;
listActions: NonNullable<ChannelActions["listActions"]>;
}): ChannelMessageActionName[] {
try {
const listed = params.listActions(params.context);
return Array.isArray(listed) ? listed : [];
} catch (error) {
logMessageActionError({
pluginId: params.pluginId,
operation: "listActions",
error,
});
return [];
}
}
function describeMessageToolSafely(params: {
pluginId: string;
context: ChannelMessageActionDiscoveryContext;
@@ -110,44 +92,6 @@ function describeMessageToolSafely(params: {
}
}
function listCapabilitiesSafely(params: {
pluginId: string;
actions: ChannelActions;
context: ChannelMessageActionDiscoveryContext;
}): readonly ChannelMessageCapability[] {
try {
return params.actions.getCapabilities?.(params.context) ?? [];
} catch (error) {
logMessageActionError({
pluginId: params.pluginId,
operation: "getCapabilities",
error,
});
return [];
}
}
function runGetToolSchemaSafely(params: {
pluginId: string;
context: ChannelMessageActionDiscoveryContext;
getToolSchema: NonNullable<ChannelActions["getToolSchema"]>;
}):
| ChannelMessageToolSchemaContribution
| ChannelMessageToolSchemaContribution[]
| null
| undefined {
try {
return params.getToolSchema(params.context);
} catch (error) {
logMessageActionError({
pluginId: params.pluginId,
operation: "getToolSchema",
error,
});
return null;
}
}
function normalizeToolSchemaContributions(
value:
| ChannelMessageToolSchemaContribution
@@ -184,52 +128,21 @@ export function resolveMessageActionDiscoveryForPlugin(params: {
};
}
if (adapter.describeMessageTool) {
const described = describeMessageToolSafely({
pluginId: params.pluginId,
context: params.context,
describeMessageTool: adapter.describeMessageTool,
});
return {
actions:
params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [],
capabilities:
params.includeCapabilities && Array.isArray(described?.capabilities)
? described.capabilities
: [],
schemaContributions: params.includeSchema
? normalizeToolSchemaContributions(described?.schema)
: [],
};
}
const described = describeMessageToolSafely({
pluginId: params.pluginId,
context: params.context,
describeMessageTool: adapter.describeMessageTool,
});
return {
actions:
params.includeActions && adapter.listActions
? runListActionsSafely({
pluginId: params.pluginId,
context: params.context,
listActions: adapter.listActions,
})
: [],
params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [],
capabilities:
params.includeCapabilities && adapter.getCapabilities
? listCapabilitiesSafely({
pluginId: params.pluginId,
actions: adapter,
context: params.context,
})
: [],
schemaContributions:
params.includeSchema && adapter.getToolSchema
? normalizeToolSchemaContributions(
runGetToolSchemaSafely({
pluginId: params.pluginId,
context: params.context,
getToolSchema: adapter.getToolSchema,
}),
)
params.includeCapabilities && Array.isArray(described?.capabilities)
? described.capabilities
: [],
schemaContributions: params.includeSchema
? normalizeToolSchemaContributions(described?.schema)
: [],
};
}

View File

@@ -23,7 +23,7 @@ const discordPlugin: ChannelPlugin = {
},
}),
actions: {
listActions: () => ["kick"],
describeMessageTool: () => ({ actions: ["kick"] }),
supportsAction: ({ action }) => action === "kick",
requiresTrustedRequesterSender: ({ action, toolContext }) =>
Boolean(action === "kick" && toolContext),

View File

@@ -41,8 +41,10 @@ function createMessageActionsPlugin(params: {
...(params.aliases ? { aliases: params.aliases } : {}),
},
actions: {
listActions: () => ["send"],
getCapabilities: () => params.capabilities,
describeMessageTool: () => ({
actions: ["send"],
capabilities: params.capabilities,
}),
},
};
}
@@ -161,16 +163,7 @@ describe("message action capability checks", () => {
).toEqual(["cards"]);
});
it("prefers unified message tool discovery over legacy discovery methods", () => {
const legacyListActions = vi.fn(() => {
throw new Error("legacy listActions should not run");
});
const legacyCapabilities = vi.fn(() => {
throw new Error("legacy getCapabilities should not run");
});
const legacySchema = vi.fn(() => {
throw new Error("legacy getToolSchema should not run");
});
it("uses unified message tool discovery for actions, capabilities, and schema", () => {
const unifiedPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "discord",
@@ -190,9 +183,6 @@ describe("message action capability checks", () => {
},
},
}),
listActions: legacyListActions,
getCapabilities: legacyCapabilities,
getToolSchema: legacySchema,
},
};
setActivePluginRegistry(
@@ -207,9 +197,6 @@ describe("message action capability checks", () => {
channel: "discord",
}),
).toHaveProperty("components");
expect(legacyListActions).not.toHaveBeenCalled();
expect(legacyCapabilities).not.toHaveBeenCalled();
expect(legacySchema).not.toHaveBeenCalled();
});
it("skips crashing action/capability discovery paths and logs once", () => {
@@ -223,10 +210,7 @@ describe("message action capability checks", () => {
},
}),
actions: {
listActions: () => {
throw new Error("boom");
},
getCapabilities: () => {
describeMessageTool: () => {
throw new Error("boom");
},
},
@@ -237,10 +221,10 @@ describe("message action capability checks", () => {
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]);
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]);
expect(errorSpy).toHaveBeenCalledTimes(2);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]);
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]);
expect(errorSpy).toHaveBeenCalledTimes(2);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -489,38 +489,14 @@ export type ChannelToolSend = {
export type ChannelMessageActionAdapter = {
/**
* Preferred unified discovery surface for the shared `message` tool.
* When provided, this is authoritative and should return the scoped actions,
* Unified discovery surface for the shared `message` tool.
* This returns the scoped actions,
* capabilities, and schema fragments together so they cannot drift.
*/
describeMessageTool?: (
describeMessageTool: (
params: ChannelMessageActionDiscoveryContext,
) => ChannelMessageToolDiscovery | null | undefined;
/**
* Advertise agent-discoverable actions for this channel.
* Legacy fallback used when `describeMessageTool` is not implemented.
* Keep this aligned with any gated capability checks. Poll discovery is
* not inferred from `outbound.sendPoll`, so channels that want agents to
* create polls should include `"poll"` here when enabled.
*/
listActions?: (params: ChannelMessageActionDiscoveryContext) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
getCapabilities?: (
params: ChannelMessageActionDiscoveryContext,
) => readonly ChannelMessageCapability[];
/**
* Extend the shared `message` tool schema with channel-owned fields.
* Legacy fallback used when `describeMessageTool` is not implemented.
* Keep this aligned with `listActions` and `getCapabilities` so the exposed
* schema matches what the channel can actually execute in the current scope.
*/
getToolSchema?: (
params: ChannelMessageActionDiscoveryContext,
) =>
| ChannelMessageToolSchemaContribution
| ChannelMessageToolSchemaContribution[]
| null
| undefined;
requiresTrustedRequesterSender?: (params: {
action: ChannelMessageActionName;
toolContext?: ChannelThreadingToolContext;

View File

@@ -118,7 +118,7 @@ function makeResolvedTokenPluginWithoutInspectAccount(): ChannelPlugin {
isEnabled: () => true,
},
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
},
};
}

View File

@@ -92,7 +92,7 @@ function createTokenOnlyPlugin() {
isEnabled: () => true,
},
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
},
};
}

View File

@@ -68,7 +68,7 @@ function buildPlugin(params: {
}
: undefined,
actions: {
listActions: () => ["poll"],
describeMessageTool: () => ({ actions: ["poll"] }),
},
};
}

View File

@@ -150,7 +150,7 @@ const createDiscordPollPluginRegistration = () => ({
id: "discord",
label: "Discord",
actions: {
listActions: () => ["poll"],
describeMessageTool: () => ({ actions: ["poll"] }),
handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => {
return await handleDiscordAction(
{ action, to: params.to, accountId: accountId ?? undefined },
@@ -168,7 +168,7 @@ const createTelegramSendPluginRegistration = () => ({
id: "telegram",
label: "Telegram",
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => {
return await handleTelegramAction(
{ action, to: params.to, accountId: accountId ?? undefined },
@@ -186,7 +186,7 @@ const createTelegramPollPluginRegistration = () => ({
id: "telegram",
label: "Telegram",
actions: {
listActions: () => ["poll"],
describeMessageTool: () => ({ actions: ["poll"] }),
handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => {
return await handleTelegramAction(
{ action, to: params.to, accountId: accountId ?? undefined },

View File

@@ -32,7 +32,7 @@ function makeMattermostPlugin(): ChannelPlugin {
isEnabled: () => true,
},
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
},
};
}

View File

@@ -67,7 +67,7 @@ function makeSlackHttpSummaryPlugin(): ChannelPlugin {
isEnabled: () => true,
},
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
},
};
}
@@ -125,7 +125,7 @@ function makeTelegramSummaryPlugin(params: {
}),
},
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
},
};
}
@@ -170,7 +170,7 @@ function makeSignalSummaryPlugin(params: { enabled: boolean; configured: boolean
isEnabled: (account) => Boolean((account as { enabled?: boolean }).enabled),
},
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
},
};
}
@@ -208,7 +208,7 @@ function makeFallbackSummaryPlugin(params: {
isEnabled: (account) => Boolean((account as { enabled?: boolean }).enabled),
},
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
},
};
}

View File

@@ -129,7 +129,7 @@ describe("runMessageAction media behavior", () => {
isConfigured: () => true,
},
actions: {
listActions: () => ["sendAttachment", "setGroupIcon"],
describeMessageTool: () => ({ actions: ["sendAttachment", "setGroupIcon"] }),
supportsAction: ({ action }) => action === "sendAttachment" || action === "setGroupIcon",
handleAction: async ({ params }) =>
jsonResult({

View File

@@ -35,7 +35,7 @@ describe("runMessageAction plugin dispatch", () => {
capabilities: { chatTypes: ["direct", "channel"] },
config: createAlwaysConfiguredPluginConfig(),
actions: {
listActions: () => ["pin", "list-pins", "member-info"],
describeMessageTool: () => ({ actions: ["pin", "list-pins", "member-info"] }),
supportsAction: ({ action }) =>
action === "pin" || action === "list-pins" || action === "member-info",
handleAction,
@@ -240,7 +240,7 @@ describe("runMessageAction plugin dispatch", () => {
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
supportsAction: ({ action }) => action === "send",
handleAction,
},
@@ -332,7 +332,7 @@ describe("runMessageAction plugin dispatch", () => {
},
},
actions: {
listActions: () => ["poll"],
describeMessageTool: () => ({ actions: ["poll"] }),
supportsAction: ({ action }) => action === "poll",
handleAction,
},
@@ -439,6 +439,7 @@ describe("runMessageAction plugin dispatch", () => {
},
},
actions: {
describeMessageTool: () => ({ actions: ["poll"] }),
supportsAction: ({ action }) => action === "poll",
handleAction,
},
@@ -521,7 +522,7 @@ describe("runMessageAction plugin dispatch", () => {
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig({}),
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
supportsAction: ({ action }) => action === "send",
handleAction,
},
@@ -603,7 +604,7 @@ describe("runMessageAction plugin dispatch", () => {
resolveAccount: () => ({}),
},
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
handleAction,
},
};

View File

@@ -18,7 +18,7 @@ export function makeDirectPlugin(params: {
capabilities: { chatTypes: ["direct"] },
config: params.config,
actions: {
listActions: () => ["send"],
describeMessageTool: () => ({ actions: ["send"] }),
},
};
}