From 682f4d1ca32213d06ccf024d4c3d43adad12b16b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:02:02 +0000 Subject: [PATCH] Plugin SDK: require unified message discovery --- extensions/bluebubbles/src/actions.test.ts | 14 +- extensions/bluebubbles/src/actions.ts | 6 +- extensions/bluebubbles/src/probe.ts | 2 +- extensions/googlechat/src/actions.ts | 6 +- extensions/googlechat/src/channel.ts | 2 +- extensions/matrix/src/actions.ts | 6 +- extensions/mattermost/src/channel.test.ts | 2 +- extensions/signal/src/channel.ts | 3 +- extensions/twitch/src/actions.ts | 2 +- extensions/whatsapp/src/channel.ts | 6 +- extensions/zalo/src/actions.ts | 7 +- extensions/zalouser/src/channel.test.ts | 7 +- extensions/zalouser/src/channel.ts | 6 +- src/agents/channel-tools.test.ts | 13 +- src/agents/tools/message-tool.test.ts | 130 +++++++++--------- src/channels/plugins/actions/actions.test.ts | 2 +- src/channels/plugins/actions/signal.ts | 8 +- src/channels/plugins/contracts/suites.ts | 22 +-- .../plugins/message-action-discovery.ts | 111 ++------------- .../plugins/message-actions.security.test.ts | 2 +- src/channels/plugins/message-actions.test.ts | 32 ++--- src/channels/plugins/types.core.ts | 30 +--- ...channels.config-only-status-output.test.ts | 2 +- .../channels.status.command-flow.test.ts | 2 +- src/commands/channels/capabilities.test.ts | 2 +- src/commands/message.test.ts | 6 +- .../channels.mattermost-token-summary.test.ts | 2 +- src/infra/channel-summary.test.ts | 8 +- .../message-action-runner.media.test.ts | 2 +- ...sage-action-runner.plugin-dispatch.test.ts | 11 +- .../channel-plugin-test-fixtures.ts | 2 +- 31 files changed, 155 insertions(+), 301 deletions(-) diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index a7a9e549051..02cda25b5bc 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -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"); diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 78cffcd2414..aeb99e8ddd3 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -67,10 +67,10 @@ const PRIVATE_API_ACTIONS = new Set([ ]); 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(); @@ -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"), diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index 135423bc0fc..8e12a621e41 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -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 { diff --git a/extensions/googlechat/src/actions.ts b/extensions/googlechat/src/actions.ts index 4685ac0bd26..463967bcd54 100644 --- a/extensions/googlechat/src/actions.ts +++ b/extensions/googlechat/src/actions.ts @@ -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([]); 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"); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 95aeccfbac2..c4ee5364643 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -98,7 +98,7 @@ const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver googlechatMessageActions.listActions?.(ctx) ?? [], + describeMessageTool: (ctx) => googlechatMessageActions.describeMessageTool?.(ctx) ?? null, extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { if (!googlechatMessageActions.handleAction) { diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index 7e555526c39..e3ef491213f 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -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(["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 => { diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 29c4cc12e0e..f8e8d86ee74 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -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: { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 17b97c96f25..80519620cc6 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -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) => { diff --git a/extensions/twitch/src/actions.ts b/extensions/twitch/src/actions.ts index 076610a652c..d67ee334d40 100644 --- a/extensions/twitch/src/actions.ts +++ b/extensions/twitch/src/actions.ts @@ -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. diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index e7f79ad5f2a..89883742a46 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -108,9 +108,9 @@ export const whatsappPlugin: ChannelPlugin = { 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(); @@ -120,7 +120,7 @@ export const whatsappPlugin: ChannelPlugin = { 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 }) => { diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 201838f0b04..b741d358c5a 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -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(["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") { diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts index 321df502b38..23ef1809e25 100644 --- a/extensions/zalouser/src/channel.test.ts +++ b/extensions/zalouser/src/channel.test.ts @@ -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", diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 4822ecb3f3e..61318d84e20 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -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 }) => { diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts index 0dad6dc3a7c..5686f46aa4a 100644 --- a/src/agents/channel-tools.test.ts +++ b/src/agents/channel-tools.test.ts @@ -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(); }); }); diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index d6c03cabf75..9d6f252a256 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -16,6 +16,12 @@ let createMessageTool: CreateMessageTool; let setActivePluginRegistry: SetActivePluginRegistry; let createTestRegistry: CreateTestRegistry; +type DescribeMessageTool = NonNullable< + NonNullable["describeMessageTool"] +>; +type MessageToolDiscoveryContext = Parameters[0]; +type MessageToolSchema = NonNullable>["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["listActions"]>; capabilities?: readonly ChannelMessageCapability[]; - toolSchema?: NonNullable["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) => { diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index b4631d03f2c..5442b2cf135 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -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); } diff --git a/src/channels/plugins/actions/signal.ts b/src/channels/plugins/actions/signal.ts index 2eacd78857c..073496ab2e2 100644 --- a/src/channels/plugins/actions/signal.ts +++ b/src/channels/plugins/actions/signal.ts @@ -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(["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", diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index 58a62d62ed3..892d4b293f9 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -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; } diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index d54aec45679..256cceb1ecc 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -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; -}): 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; -}): - | 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) + : [], }; } diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index ed178a9e2fa..e025f601404 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -23,7 +23,7 @@ const discordPlugin: ChannelPlugin = { }, }), actions: { - listActions: () => ["kick"], + describeMessageTool: () => ({ actions: ["kick"] }), supportsAction: ({ action }) => action === "kick", requiresTrustedRequesterSender: ({ action, toolContext }) => Boolean(action === "kick" && toolContext), diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 396b82a498c..1130adc8031 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -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); }); }); diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 15b66bd6456..668a47c750b 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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; diff --git a/src/commands/channels.config-only-status-output.test.ts b/src/commands/channels.config-only-status-output.test.ts index 7019c84bb3a..188f24eaf35 100644 --- a/src/commands/channels.config-only-status-output.test.ts +++ b/src/commands/channels.config-only-status-output.test.ts @@ -118,7 +118,7 @@ function makeResolvedTokenPluginWithoutInspectAccount(): ChannelPlugin { isEnabled: () => true, }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index e613c64323a..85347c56bf9 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -92,7 +92,7 @@ function createTokenOnlyPlugin() { isEnabled: () => true, }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index 3a70bdb85f9..f907ac4ca0e 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -68,7 +68,7 @@ function buildPlugin(params: { } : undefined, actions: { - listActions: () => ["poll"], + describeMessageTool: () => ({ actions: ["poll"] }), }, }; } diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 806dc2655d1..29df194cf2d 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -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 }, diff --git a/src/commands/status-all/channels.mattermost-token-summary.test.ts b/src/commands/status-all/channels.mattermost-token-summary.test.ts index a012a3a3647..3bf59d1104d 100644 --- a/src/commands/status-all/channels.mattermost-token-summary.test.ts +++ b/src/commands/status-all/channels.mattermost-token-summary.test.ts @@ -32,7 +32,7 @@ function makeMattermostPlugin(): ChannelPlugin { isEnabled: () => true, }, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; } diff --git a/src/infra/channel-summary.test.ts b/src/infra/channel-summary.test.ts index 12cfa8bbbae..24eb8ca966d 100644 --- a/src/infra/channel-summary.test.ts +++ b/src/infra/channel-summary.test.ts @@ -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"] }), }, }; } diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index fbbb9e6e2c8..292b301a8b7 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -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({ diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 55290b8d9d1..6f3d3fd0f03 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -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, }, }; diff --git a/src/test-utils/channel-plugin-test-fixtures.ts b/src/test-utils/channel-plugin-test-fixtures.ts index 39f5a617787..a32c2837748 100644 --- a/src/test-utils/channel-plugin-test-fixtures.ts +++ b/src/test-utils/channel-plugin-test-fixtures.ts @@ -18,7 +18,7 @@ export function makeDirectPlugin(params: { capabilities: { chatTypes: ["direct"] }, config: params.config, actions: { - listActions: () => ["send"], + describeMessageTool: () => ({ actions: ["send"] }), }, }; }