From a14ad01d66f8c2ed62dad23e9de6f443c0027968 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Mar 2026 23:47:45 +0000 Subject: [PATCH] Plugin SDK: centralize message tool discovery and context --- src/agents/channel-tools.ts | 46 ++- src/agents/openclaw-tools.ts | 1 + src/agents/pi-embedded-runner/compact.ts | 12 +- src/agents/pi-embedded-runner/run/attempt.ts | 7 + src/agents/tools/message-tool.test.ts | 177 +++++++- src/agents/tools/message-tool.ts | 379 +++++------------- src/channels/plugins/message-actions.ts | 145 ++++++- src/channels/plugins/message-tool-schema.ts | 161 ++++++++ src/channels/plugins/types.core.ts | 52 ++- src/channels/plugins/types.ts | 2 + ...sage-action-runner.plugin-dispatch.test.ts | 44 ++ src/infra/outbound/message-action-runner.ts | 7 +- src/plugin-sdk/channel-runtime.ts | 1 + 13 files changed, 737 insertions(+), 297 deletions(-) create mode 100644 src/channels/plugins/message-tool-schema.ts diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 242cce868c1..4e2d028e91a 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -15,6 +15,14 @@ import { defaultRuntime } from "../runtime.js"; export function listChannelSupportedActions(params: { cfg?: OpenClawConfig; channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; }): ChannelMessageActionName[] { if (!params.channel) { return []; @@ -24,7 +32,18 @@ export function listChannelSupportedActions(params: { return []; } const cfg = params.cfg ?? ({} as OpenClawConfig); - return runPluginListActions(plugin, cfg); + return runPluginListActions(plugin, { + cfg, + currentChannelId: params.currentChannelId, + currentChannelProvider: params.channel, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }); } /** @@ -32,6 +51,14 @@ export function listChannelSupportedActions(params: { */ export function listAllChannelSupportedActions(params: { cfg?: OpenClawConfig; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; }): ChannelMessageActionName[] { const actions = new Set(); for (const plugin of listChannelPlugins()) { @@ -39,7 +66,18 @@ export function listAllChannelSupportedActions(params: { continue; } const cfg = params.cfg ?? ({} as OpenClawConfig); - const channelActions = runPluginListActions(plugin, cfg); + const channelActions = runPluginListActions(plugin, { + cfg, + currentChannelId: params.currentChannelId, + currentChannelProvider: plugin.id, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }); for (const action of channelActions) { actions.add(action); } @@ -86,13 +124,13 @@ const loggedListActionErrors = new Set(); function runPluginListActions( plugin: ChannelPlugin, - cfg: OpenClawConfig, + context: Parameters["listActions"]>>[0], ): ChannelMessageActionName[] { if (!plugin.actions?.listActions) { return []; } try { - const listed = plugin.actions.listActions({ cfg }); + const listed = plugin.actions.listActions(context); return Array.isArray(listed) ? listed : []; } catch (err) { logListActionsError(plugin.id, err); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 6f4929d288a..de5e91fdf0c 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -135,6 +135,7 @@ export function createOpenClawTools( : createMessageTool({ agentAccountId: options?.agentAccountId, agentSessionKey: options?.agentSessionKey, + sessionId: options?.sessionId, config: options?.config, currentChannelId: options?.currentChannelId, currentChannelProvider: options?.agentChannel, diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 4e967730667..7893f51b70c 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -649,11 +649,19 @@ export async function compactEmbeddedPiSessionDirect( return undefined; })() : undefined; + const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.config, + }); // Resolve channel-specific message actions for system prompt const channelActions = runtimeChannel ? listChannelSupportedActions({ cfg: params.config, channel: runtimeChannel, + accountId: params.agentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: sessionAgentId, }) : undefined; const messageToolHints = runtimeChannel @@ -680,10 +688,6 @@ export async function compactEmbeddedPiSessionDirect( const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); - const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ - sessionKey: params.sessionKey, - config: params.config, - }); const isDefaultAgent = sessionAgentId === defaultAgentId; const promptMode = isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 73b7d0fbff6..0fa03797a60 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1623,6 +1623,13 @@ export async function runEmbeddedAttempt( ? listChannelSupportedActions({ cfg: params.config, channel: runtimeChannel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.agentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: sessionAgentId, }) : undefined; const messageToolHints = runtimeChannel diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 88062eacaa7..2693e7fdf19 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -1,10 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; +import { + createDiscordMessageToolComponentsSchema, + createMessageToolButtonsSchema, + createSlackMessageToolBlocksSchema, + createTelegramPollExtraToolSchemas, +} from "../../channels/plugins/message-tool-schema.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { createMessageTool } from "./message-tool.js"; +type CreateMessageTool = typeof import("./message-tool.js").createMessageTool; +type SetActivePluginRegistry = typeof import("../../plugins/runtime.js").setActivePluginRegistry; +type CreateTestRegistry = typeof import("../../test-utils/channel-plugins.js").createTestRegistry; + +let createMessageTool: CreateMessageTool; +let setActivePluginRegistry: SetActivePluginRegistry; +let createTestRegistry: CreateTestRegistry; const mocks = vi.hoisted(() => ({ runMessageAction: vi.fn(), @@ -50,7 +60,7 @@ function mockSendResult(overrides: { channel?: string; to?: string } = {}) { } satisfies MessageActionRunResult); } -function getToolProperties(tool: ReturnType) { +function getToolProperties(tool: ReturnType) { return (tool.parameters as { properties?: Record }).properties ?? {}; } @@ -58,13 +68,17 @@ function getActionEnum(properties: Record) { return (properties.action as { enum?: string[] } | undefined)?.enum ?? []; } -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); mocks.runMessageAction.mockReset(); mocks.loadConfig.mockReset().mockReturnValue({}); mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({ resolvedConfig: config, diagnostics: [], })); + ({ setActivePluginRegistry } = await import("../../plugins/runtime.js")); + ({ createTestRegistry } = await import("../../test-utils/channel-plugins.js")); + ({ createMessageTool } = await import("./message-tool.js")); }); function createChannelPlugin(params: { @@ -75,6 +89,7 @@ function createChannelPlugin(params: { actions?: ChannelMessageActionName[]; listActions?: NonNullable["listActions"]>; capabilities?: readonly ChannelMessageCapability[]; + toolSchema?: NonNullable["getToolSchema"]>; messaging?: ChannelPlugin["messaging"]; }): ChannelPlugin { const actionCapabilities = params.capabilities; @@ -102,6 +117,7 @@ function createChannelPlugin(params: { ...(actionCapabilities ? { getCapabilities: (_params: { cfg: unknown }) => actionCapabilities } : {}), + ...(params.toolSchema ? { getToolSchema: params.toolSchema } : {}), }, }; } @@ -219,6 +235,17 @@ describe("message tool schema scoping", () => { blurb: "Telegram test plugin.", actions: ["send", "react", "poll"], capabilities: ["interactive", "buttons"], + toolSchema: () => [ + { + properties: { + buttons: createMessageToolButtonsSchema(), + }, + }, + { + properties: createTelegramPollExtraToolSchemas(), + visibility: "all-configured", + }, + ], }); const discordPlugin = createChannelPlugin({ @@ -228,6 +255,11 @@ describe("message tool schema scoping", () => { blurb: "Discord test plugin.", actions: ["send", "poll", "poll-vote"], capabilities: ["interactive", "components"], + toolSchema: () => ({ + properties: { + components: createDiscordMessageToolComponentsSchema(), + }, + }), }); const slackPlugin = createChannelPlugin({ @@ -237,6 +269,11 @@ describe("message tool schema scoping", () => { blurb: "Slack test plugin.", actions: ["send", "react"], capabilities: ["interactive", "blocks"], + toolSchema: () => ({ + properties: { + blocks: createSlackMessageToolBlocksSchema(), + }, + }), }); afterEach(() => { @@ -365,6 +402,25 @@ describe("message tool schema scoping", () => { 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(), + }, + }, + ...(telegramCfg?.actions?.poll === false + ? [] + : [ + { + properties: createTelegramPollExtraToolSchemas(), + visibility: "all-configured" as const, + }, + ]), + ]; + }, }); setActivePluginRegistry( @@ -393,6 +449,95 @@ describe("message tool schema scoping", () => { expect(properties.pollAnonymous).toBeUndefined(); expect(properties.pollPublic).toBeUndefined(); }); + + it("uses discovery account scope for capability-gated shared fields", () => { + const scopedInteractivePlugin = createChannelPlugin({ + id: "telegram", + label: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test plugin.", + actions: ["send"], + toolSchema: () => null, + }); + scopedInteractivePlugin.actions = { + ...scopedInteractivePlugin.actions, + getCapabilities: ({ accountId }) => (accountId === "ops" ? ["interactive"] : []), + }; + + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "telegram", source: "test", plugin: scopedInteractivePlugin }, + ]), + ); + + const scopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "telegram", + agentAccountId: "ops", + }); + const unscopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "telegram", + }); + + expect(getToolProperties(scopedTool).interactive).toBeDefined(); + expect(getToolProperties(unscopedTool).interactive).toBeUndefined(); + }); + + it("routes full discovery context into plugin action discovery", () => { + const seenContexts: Record[] = []; + const contextPlugin = createChannelPlugin({ + id: "discord", + 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; + }, + }); + contextPlugin.actions = { + ...contextPlugin.actions, + getCapabilities: (ctx) => { + seenContexts.push({ phase: "getCapabilities", ...ctx }); + return ["interactive"]; + }, + }; + + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", source: "test", plugin: contextPlugin }]), + ); + + createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + currentChannelId: "channel:123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + agentAccountId: "ops", + agentSessionKey: "agent:alpha:main", + sessionId: "session-123", + requesterSenderId: "user-42", + }); + + expect(seenContexts).toContainEqual( + expect.objectContaining({ + currentChannelProvider: "discord", + currentChannelId: "channel:123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + accountId: "ops", + sessionKey: "agent:alpha:main", + sessionId: "session-123", + agentId: "alpha", + requesterSenderId: "user-42", + }), + ); + }); }); describe("message tool description", () => { @@ -405,7 +550,27 @@ describe("message tool description", () => { label: "BlueBubbles", docsPath: "/channels/bluebubbles", blurb: "BlueBubbles test plugin.", - actions: ["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"], + listActions: ({ currentChannelId }) => { + const all: ChannelMessageActionName[] = [ + "react", + "renameGroup", + "addParticipant", + "removeParticipant", + "leaveGroup", + ]; + 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; + }, messaging: { normalizeTarget: (raw) => { const trimmed = raw.trim().replace(/^bluebubbles:/i, ""); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 1dcaf04e1f0..f5428519f81 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,10 +1,10 @@ import { Type } from "@sinclair/typebox"; -import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { channelSupportsMessageCapability, channelSupportsMessageCapabilityForChannel, listChannelMessageActions, + resolveChannelMessageToolSchemaProperties, } from "../../channels/plugins/message-actions.js"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import { @@ -18,7 +18,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; -import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import { POLL_CREATION_PARAM_DEFS, POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; @@ -53,116 +52,6 @@ function buildRoutingSchema() { }; } -const discordComponentEmojiSchema = Type.Object({ - name: Type.String(), - id: Type.Optional(Type.String()), - animated: Type.Optional(Type.Boolean()), -}); - -const discordComponentOptionSchema = Type.Object({ - label: Type.String(), - value: Type.String(), - description: Type.Optional(Type.String()), - emoji: Type.Optional(discordComponentEmojiSchema), - default: Type.Optional(Type.Boolean()), -}); - -const discordComponentButtonSchema = Type.Object({ - label: Type.String(), - style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), - url: Type.Optional(Type.String()), - emoji: Type.Optional(discordComponentEmojiSchema), - disabled: Type.Optional(Type.Boolean()), - allowedUsers: Type.Optional( - Type.Array( - Type.String({ - description: "Discord user ids or names allowed to interact with this button.", - }), - ), - ), -}); - -const discordComponentSelectSchema = Type.Object({ - type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])), - placeholder: Type.Optional(Type.String()), - minValues: Type.Optional(Type.Number()), - maxValues: Type.Optional(Type.Number()), - options: Type.Optional(Type.Array(discordComponentOptionSchema)), -}); - -const discordComponentBlockSchema = Type.Object({ - type: Type.String(), - text: Type.Optional(Type.String()), - texts: Type.Optional(Type.Array(Type.String())), - accessory: Type.Optional( - Type.Object({ - type: Type.String(), - url: Type.Optional(Type.String()), - button: Type.Optional(discordComponentButtonSchema), - }), - ), - spacing: Type.Optional(stringEnum(["small", "large"])), - divider: Type.Optional(Type.Boolean()), - buttons: Type.Optional(Type.Array(discordComponentButtonSchema)), - select: Type.Optional(discordComponentSelectSchema), - items: Type.Optional( - Type.Array( - Type.Object({ - url: Type.String(), - description: Type.Optional(Type.String()), - spoiler: Type.Optional(Type.Boolean()), - }), - ), - ), - file: Type.Optional(Type.String()), - spoiler: Type.Optional(Type.Boolean()), -}); - -const discordComponentModalFieldSchema = Type.Object({ - type: Type.String(), - name: Type.Optional(Type.String()), - label: Type.String(), - description: Type.Optional(Type.String()), - placeholder: Type.Optional(Type.String()), - required: Type.Optional(Type.Boolean()), - options: Type.Optional(Type.Array(discordComponentOptionSchema)), - minValues: Type.Optional(Type.Number()), - maxValues: Type.Optional(Type.Number()), - minLength: Type.Optional(Type.Number()), - maxLength: Type.Optional(Type.Number()), - style: Type.Optional(stringEnum(["short", "paragraph"])), -}); - -const discordComponentModalSchema = Type.Object({ - title: Type.String(), - triggerLabel: Type.Optional(Type.String()), - triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), - fields: Type.Array(discordComponentModalFieldSchema), -}); - -const discordComponentMessageSchema = Type.Object( - { - text: Type.Optional(Type.String()), - reusable: Type.Optional( - Type.Boolean({ - description: "Allow components to be used multiple times until they expire.", - }), - ), - container: Type.Optional( - Type.Object({ - accentColor: Type.Optional(Type.String()), - spoiler: Type.Optional(Type.Boolean()), - }), - ), - blocks: Type.Optional(Type.Array(discordComponentBlockSchema)), - modal: Type.Optional(discordComponentModalSchema), - }, - { - description: - "Discord components v2 payload. Set reusable=true to keep buttons, selects, and forms active until expiry.", - }, -); - const interactiveOptionSchema = Type.Object({ label: Type.String(), value: Type.String(), @@ -192,13 +81,7 @@ const interactiveMessageSchema = Type.Object( }, ); -function buildSendSchema(options: { - includeInteractive: boolean; - includeButtons: boolean; - includeCards: boolean; - includeComponents: boolean; - includeBlocks: boolean; -}) { +function buildSendSchema(options: { includeInteractive: boolean }) { const props: Record = { message: Type.Optional(Type.String()), effectId: Type.Optional( @@ -240,57 +123,10 @@ function buildSendSchema(options: { }), ), interactive: Type.Optional(interactiveMessageSchema), - buttons: Type.Optional( - Type.Array( - Type.Array( - Type.Object({ - text: Type.String(), - callback_data: Type.String(), - style: Type.Optional(stringEnum(["danger", "success", "primary"])), - }), - ), - { - description: "Telegram inline keyboard buttons (array of button rows)", - }, - ), - ), - card: Type.Optional( - Type.Object( - {}, - { - additionalProperties: true, - description: "Adaptive Card JSON object (when supported by the channel)", - }, - ), - ), - components: Type.Optional(discordComponentMessageSchema), - blocks: Type.Optional( - Type.Array( - Type.Object( - {}, - { - additionalProperties: true, - description: "Slack Block Kit payload blocks (Slack only).", - }, - ), - ), - ), }; - if (!options.includeButtons) { - delete props.buttons; - } if (!options.includeInteractive) { delete props.interactive; } - if (!options.includeCards) { - delete props.card; - } - if (!options.includeComponents) { - delete props.components; - } - if (!options.includeBlocks) { - delete props.blocks; - } return props; } @@ -330,7 +166,7 @@ function buildFetchSchema() { }; } -function buildPollSchema(options?: { includeTelegramExtras?: boolean }) { +function buildPollSchema() { const props: Record = { pollId: Type.Optional(Type.String()), pollOptionId: Type.Optional( @@ -363,7 +199,7 @@ function buildPollSchema(options?: { includeTelegramExtras?: boolean }) { }; for (const name of POLL_CREATION_PARAM_NAMES) { const def = POLL_CREATION_PARAM_DEFS[name]; - if (def.telegramOnly && !options?.includeTelegramExtras) { + if (def.telegramOnly) { continue; } switch (def.kind) { @@ -510,18 +346,14 @@ function buildChannelManagementSchema() { function buildMessageToolSchemaProps(options: { includeInteractive: boolean; - includeButtons: boolean; - includeCards: boolean; - includeComponents: boolean; - includeBlocks: boolean; - includeTelegramPollExtras: boolean; + extraProperties?: Record; }) { return { ...buildRoutingSchema(), ...buildSendSchema(options), ...buildReactionSchema(), ...buildFetchSchema(), - ...buildPollSchema({ includeTelegramExtras: options.includeTelegramPollExtras }), + ...buildPollSchema(), ...buildChannelTargetSchema(), ...buildStickerSchema(), ...buildThreadSchema(), @@ -530,6 +362,7 @@ function buildMessageToolSchemaProps(options: { ...buildGatewaySchema(), ...buildChannelManagementSchema(), ...buildPresenceSchema(), + ...options.extraProperties, }; } @@ -537,11 +370,7 @@ function buildMessageToolSchemaFromActions( actions: readonly string[], options: { includeInteractive: boolean; - includeButtons: boolean; - includeCards: boolean; - includeComponents: boolean; - includeBlocks: boolean; - includeTelegramPollExtras: boolean; + extraProperties?: Record; }, ) { const props = buildMessageToolSchemaProps(options); @@ -553,16 +382,12 @@ function buildMessageToolSchemaFromActions( const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { includeInteractive: true, - includeButtons: true, - includeCards: true, - includeComponents: true, - includeBlocks: true, - includeTelegramPollExtras: true, }); type MessageToolOptions = { agentAccountId?: string; agentSessionKey?: string; + sessionId?: string; config?: OpenClawConfig; currentChannelId?: string; currentChannelProvider?: string; @@ -579,16 +404,27 @@ function resolveMessageToolSchemaActions(params: { cfg: OpenClawConfig; currentChannelProvider?: string; currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }): string[] { const currentChannel = normalizeMessageChannel(params.currentChannelProvider); if (currentChannel) { - const scopedActions = filterActionsForContext({ - actions: listChannelSupportedActions({ - cfg: params.cfg, - channel: currentChannel, - }), + const scopedActions = listChannelSupportedActions({ + cfg: params.cfg, channel: currentChannel, currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, }); const allActions = new Set(["send", ...scopedActions]); // Include actions from other configured channels so isolated/cron agents @@ -611,6 +447,14 @@ function resolveIncludeCapability( params: { cfg: OpenClawConfig; currentChannelProvider?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }, capability: ChannelMessageCapability, ): boolean { @@ -620,6 +464,14 @@ function resolveIncludeCapability( { cfg: params.cfg, channel: currentChannel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, }, capability, ); @@ -627,70 +479,50 @@ function resolveIncludeCapability( return channelSupportsMessageCapability(params.cfg, capability); } -function resolveIncludeComponents(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "components"); -} - function resolveIncludeInteractive(params: { cfg: OpenClawConfig; currentChannelProvider?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }): boolean { return resolveIncludeCapability(params, "interactive"); } -function resolveIncludeButtons(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "buttons"); -} - -function resolveIncludeCards(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "cards"); -} - -function resolveIncludeBlocks(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "blocks"); -} - -function resolveIncludeTelegramPollExtras(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return listChannelSupportedActions({ - cfg: params.cfg, - channel: "telegram", - }).includes("poll"); -} - function buildMessageToolSchema(params: { cfg: OpenClawConfig; currentChannelProvider?: string; currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }) { const actions = resolveMessageToolSchemaActions(params); const includeInteractive = resolveIncludeInteractive(params); - const includeButtons = resolveIncludeButtons(params); - const includeCards = resolveIncludeCards(params); - const includeComponents = resolveIncludeComponents(params); - const includeBlocks = resolveIncludeBlocks(params); - const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params); + const extraProperties = resolveChannelMessageToolSchemaProperties({ + cfg: params.cfg, + channel: normalizeMessageChannel(params.currentChannelProvider), + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }); return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { includeInteractive, - includeButtons, - includeCards, - includeComponents, - includeBlocks, - includeTelegramPollExtras, + extraProperties, }); } @@ -702,49 +534,33 @@ function resolveAgentAccountId(value?: string): string | undefined { return normalizeAccountId(trimmed); } -function filterActionsForContext(params: { - actions: ChannelMessageActionName[]; - channel?: string; - currentChannelId?: string; -}): ChannelMessageActionName[] { - const channel = normalizeMessageChannel(params.channel); - if (!channel || channel !== "bluebubbles") { - return params.actions; - } - const currentChannelId = params.currentChannelId?.trim(); - if (!currentChannelId) { - return params.actions; - } - const normalizedTarget = - normalizeTargetForProvider(channel, currentChannelId) ?? currentChannelId; - const lowered = normalizedTarget.trim().toLowerCase(); - const isGroupTarget = - lowered.startsWith("chat_guid:") || - lowered.startsWith("chat_id:") || - lowered.startsWith("chat_identifier:") || - lowered.startsWith("group:"); - if (isGroupTarget) { - return params.actions; - } - return params.actions.filter((action) => !BLUEBUBBLES_GROUP_ACTIONS.has(action)); -} - function buildMessageToolDescription(options?: { config?: OpenClawConfig; currentChannel?: string; currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }): string { const baseDescription = "Send, delete, and manage messages via channel plugins."; // If we have a current channel, show its actions and list other configured channels if (options?.currentChannel) { - const channelActions = filterActionsForContext({ - actions: listChannelSupportedActions({ - cfg: options.config, - channel: options.currentChannel, - }), + const channelActions = listChannelSupportedActions({ + cfg: options.config, channel: options.currentChannel, currentChannelId: options.currentChannelId, + currentThreadTs: options.currentThreadTs, + currentMessageId: options.currentMessageId, + accountId: options.currentAccountId, + sessionKey: options.sessionKey, + sessionId: options.sessionId, + agentId: options.agentId, + requesterSenderId: options.requesterSenderId, }); if (channelActions.length > 0) { // Always include "send" as a base action @@ -785,17 +601,37 @@ function buildMessageToolDescription(options?: { export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const agentAccountId = resolveAgentAccountId(options?.agentAccountId); + const resolvedAgentId = options?.agentSessionKey + ? resolveSessionAgentId({ + sessionKey: options.agentSessionKey, + config: options?.config, + }) + : undefined; const schema = options?.config ? buildMessageToolSchema({ cfg: options.config, currentChannelProvider: options.currentChannelProvider, currentChannelId: options.currentChannelId, + currentThreadTs: options.currentThreadTs, + currentMessageId: options.currentMessageId, + currentAccountId: agentAccountId, + sessionKey: options.agentSessionKey, + sessionId: options.sessionId, + agentId: resolvedAgentId, + requesterSenderId: options.requesterSenderId, }) : MessageToolSchema; const description = buildMessageToolDescription({ config: options?.config, currentChannel: options?.currentChannelProvider, currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + currentMessageId: options?.currentMessageId, + currentAccountId: agentAccountId, + sessionKey: options?.agentSessionKey, + sessionId: options?.sessionId, + agentId: resolvedAgentId, + requesterSenderId: options?.requesterSenderId, }); return { @@ -917,9 +753,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { gateway, toolContext, sessionKey: options?.agentSessionKey, - agentId: options?.agentSessionKey - ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) - : undefined, + sessionId: options?.sessionId, + agentId: resolvedAgentId, sandboxRoot: options?.sandboxRoot, abortSignal: signal, }); diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 07d08171582..3a7cdad7e66 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -1,9 +1,15 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { TSchema } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { getChannelPlugin, listChannelPlugins } from "./index.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; -import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js"; +import type { + ChannelMessageActionContext, + ChannelMessageActionDiscoveryContext, + ChannelMessageActionName, + ChannelMessageToolSchemaContribution, +} from "./types.js"; type ChannelActions = NonNullable>["actions"]>; @@ -38,11 +44,11 @@ function logMessageActionError(params: { function runListActionsSafely(params: { pluginId: string; - cfg: OpenClawConfig; + context: ChannelMessageActionDiscoveryContext; listActions: NonNullable; }): ChannelMessageActionName[] { try { - const listed = params.listActions({ cfg: params.cfg }); + const listed = params.listActions(params.context); return Array.isArray(listed) ? listed : []; } catch (error) { logMessageActionError({ @@ -62,7 +68,7 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc } const list = runListActionsSafely({ pluginId: plugin.id, - cfg, + context: { cfg }, listActions: plugin.actions.listActions, }); for (const action of list) { @@ -75,10 +81,10 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc function listCapabilities(params: { pluginId: string; actions: ChannelActions; - cfg: OpenClawConfig; + context: ChannelMessageActionDiscoveryContext; }): readonly ChannelMessageCapability[] { try { - return params.actions.getCapabilities?.({ cfg: params.cfg }) ?? []; + return params.actions.getCapabilities?.(params.context) ?? []; } catch (error) { logMessageActionError({ pluginId: params.pluginId, @@ -98,7 +104,7 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess for (const capability of listCapabilities({ pluginId: plugin.id, actions: plugin.actions, - cfg, + context: { cfg }, })) { capabilities.add(capability); } @@ -109,6 +115,14 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess export function listChannelMessageCapabilitiesForChannel(params: { cfg: OpenClawConfig; channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; }): ChannelMessageCapability[] { if (!params.channel) { return []; @@ -119,12 +133,119 @@ export function listChannelMessageCapabilitiesForChannel(params: { listCapabilities({ pluginId: plugin.id, actions: plugin.actions, - cfg: params.cfg, + context: { + cfg: params.cfg, + currentChannelProvider: params.channel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }, }), ) : []; } +function logMessageActionSchemaError(params: { pluginId: string; error: unknown }) { + const message = params.error instanceof Error ? params.error.message : String(params.error); + const key = `${params.pluginId}:getToolSchema:${message}`; + if (loggedMessageActionErrors.has(key)) { + return; + } + loggedMessageActionErrors.add(key); + const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null; + defaultRuntime.error?.( + `[message-actions] ${params.pluginId}.actions.getToolSchema failed: ${stack ?? message}`, + ); +} + +function normalizeToolSchemaContributions( + value: + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined, +): ChannelMessageToolSchemaContribution[] { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +function mergeToolSchemaProperties( + target: Record, + source: Record | undefined, +) { + if (!source) { + return; + } + for (const [name, schema] of Object.entries(source)) { + if (!(name in target)) { + target[name] = schema; + } + } +} + +export function resolveChannelMessageToolSchemaProperties(params: { + cfg: OpenClawConfig; + channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}): Record { + const properties: Record = {}; + const plugins = listChannelPlugins(); + const currentChannel = params.channel?.trim() || undefined; + const discoveryBase: ChannelMessageActionDiscoveryContext = { + cfg: params.cfg, + currentChannelId: params.currentChannelId, + currentChannelProvider: currentChannel, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }; + + for (const plugin of plugins) { + const getToolSchema = plugin?.actions?.getToolSchema; + if (!plugin || !getToolSchema) { + continue; + } + try { + const contributions = normalizeToolSchemaContributions(getToolSchema(discoveryBase)); + for (const contribution of contributions) { + const visibility = contribution.visibility ?? "current-channel"; + if (currentChannel) { + if (visibility === "all-configured" || plugin.id === currentChannel) { + mergeToolSchemaProperties(properties, contribution.properties); + } + continue; + } + mergeToolSchemaProperties(properties, contribution.properties); + } + } catch (error) { + logMessageActionSchemaError({ + pluginId: plugin.id, + error, + }); + } + } + + return properties; +} + export function channelSupportsMessageCapability( cfg: OpenClawConfig, capability: ChannelMessageCapability, @@ -136,6 +257,14 @@ export function channelSupportsMessageCapabilityForChannel( params: { cfg: OpenClawConfig; channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; }, capability: ChannelMessageCapability, ): boolean { diff --git a/src/channels/plugins/message-tool-schema.ts b/src/channels/plugins/message-tool-schema.ts new file mode 100644 index 00000000000..790b2118ee9 --- /dev/null +++ b/src/channels/plugins/message-tool-schema.ts @@ -0,0 +1,161 @@ +import { Type } from "@sinclair/typebox"; +import type { TSchema } from "@sinclair/typebox"; +import { stringEnum } from "../../agents/schema/typebox.js"; + +const discordComponentEmojiSchema = Type.Object({ + name: Type.String(), + id: Type.Optional(Type.String()), + animated: Type.Optional(Type.Boolean()), +}); + +const discordComponentOptionSchema = Type.Object({ + label: Type.String(), + value: Type.String(), + description: Type.Optional(Type.String()), + emoji: Type.Optional(discordComponentEmojiSchema), + default: Type.Optional(Type.Boolean()), +}); + +const discordComponentButtonSchema = Type.Object({ + label: Type.String(), + style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), + url: Type.Optional(Type.String()), + emoji: Type.Optional(discordComponentEmojiSchema), + disabled: Type.Optional(Type.Boolean()), + allowedUsers: Type.Optional( + Type.Array( + Type.String({ + description: "Discord user ids or names allowed to interact with this button.", + }), + ), + ), +}); + +const discordComponentSelectSchema = Type.Object({ + type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])), + placeholder: Type.Optional(Type.String()), + minValues: Type.Optional(Type.Number()), + maxValues: Type.Optional(Type.Number()), + options: Type.Optional(Type.Array(discordComponentOptionSchema)), +}); + +const discordComponentBlockSchema = Type.Object({ + type: Type.String(), + text: Type.Optional(Type.String()), + texts: Type.Optional(Type.Array(Type.String())), + accessory: Type.Optional( + Type.Object({ + type: Type.String(), + url: Type.Optional(Type.String()), + button: Type.Optional(discordComponentButtonSchema), + }), + ), + spacing: Type.Optional(stringEnum(["small", "large"])), + divider: Type.Optional(Type.Boolean()), + buttons: Type.Optional(Type.Array(discordComponentButtonSchema)), + select: Type.Optional(discordComponentSelectSchema), + items: Type.Optional( + Type.Array( + Type.Object({ + url: Type.String(), + description: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), + }), + ), + ), + file: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), +}); + +const discordComponentModalFieldSchema = Type.Object({ + type: Type.String(), + name: Type.Optional(Type.String()), + label: Type.String(), + description: Type.Optional(Type.String()), + placeholder: Type.Optional(Type.String()), + required: Type.Optional(Type.Boolean()), + options: Type.Optional(Type.Array(discordComponentOptionSchema)), + minValues: Type.Optional(Type.Number()), + maxValues: Type.Optional(Type.Number()), + minLength: Type.Optional(Type.Number()), + maxLength: Type.Optional(Type.Number()), + style: Type.Optional(stringEnum(["short", "paragraph"])), +}); + +const discordComponentModalSchema = Type.Object({ + title: Type.String(), + triggerLabel: Type.Optional(Type.String()), + triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), + fields: Type.Array(discordComponentModalFieldSchema), +}); + +export function createMessageToolButtonsSchema(): TSchema { + return Type.Array( + Type.Array( + Type.Object({ + text: Type.String(), + callback_data: Type.String(), + style: Type.Optional(stringEnum(["danger", "success", "primary"])), + }), + ), + { + description: "Button rows for channels that support button-style actions.", + }, + ); +} + +export function createMessageToolCardSchema(): TSchema { + return Type.Object( + {}, + { + additionalProperties: true, + description: "Structured card payload for channels that support card-style messages.", + }, + ); +} + +export function createDiscordMessageToolComponentsSchema(): TSchema { + return Type.Object( + { + text: Type.Optional(Type.String()), + reusable: Type.Optional( + Type.Boolean({ + description: "Allow components to be used multiple times until they expire.", + }), + ), + container: Type.Optional( + Type.Object({ + accentColor: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), + }), + ), + blocks: Type.Optional(Type.Array(discordComponentBlockSchema)), + modal: Type.Optional(discordComponentModalSchema), + }, + { + description: + "Discord components v2 payload. Set reusable=true to keep buttons, selects, and forms active until expiry.", + }, + ); +} + +export function createSlackMessageToolBlocksSchema(): TSchema { + return Type.Array( + Type.Object( + {}, + { + additionalProperties: true, + description: "Slack Block Kit payload blocks (Slack only).", + }, + ), + ); +} + +export function createTelegramPollExtraToolSchemas(): Record { + return { + pollDurationHours: Type.Optional(Type.Number()), + pollDurationSeconds: Type.Optional(Type.Number()), + pollAnonymous: Type.Optional(Type.Boolean()), + pollPublic: Type.Optional(Type.Boolean()), + }; +} diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index a43dbb42876..573046bb04b 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -21,6 +21,37 @@ export type ChannelAgentTool = AgentTool & { export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => ChannelAgentTool[]; +/** + * Discovery-time inputs passed to channel action adapters when the core is + * asking what an agent should be allowed to see. This is intentionally + * smaller than execution context: it carries routing/account scope, but no + * tool params or runtime handles. + */ +export type ChannelMessageActionDiscoveryContext = { + cfg: OpenClawConfig; + currentChannelId?: string | null; + currentChannelProvider?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}; + +/** + * Plugin-owned schema fragments for the shared `message` tool. + * `current-channel` means expose the fields only when that provider is the + * active runtime channel. `all-configured` keeps the fields visible even while + * another configured channel is active, which is useful for cross-channel + * sends from cron or isolated agents. + */ +export type ChannelMessageToolSchemaContribution = { + properties: Record; + visibility?: "current-channel" | "all-configured"; +}; + export type ChannelSetupInput = { name?: string; token?: string; @@ -424,6 +455,9 @@ export type ChannelMessageActionContext = { * never be sourced from tool/model-controlled params. */ requesterSenderId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; gateway?: { url?: string; token?: string; @@ -449,9 +483,23 @@ export type ChannelMessageActionAdapter = { * not inferred from `outbound.sendPoll`, so channels that want agents to * create polls should include `"poll"` here when enabled. */ - listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[]; + listActions?: (params: ChannelMessageActionDiscoveryContext) => ChannelMessageActionName[]; supportsAction?: (params: { action: ChannelMessageActionName }) => boolean; - getCapabilities?: (params: { cfg: OpenClawConfig }) => readonly ChannelMessageCapability[]; + getCapabilities?: ( + params: ChannelMessageActionDiscoveryContext, + ) => readonly ChannelMessageCapability[]; + /** + * Extend the shared `message` tool schema with channel-owned fields. + * 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/channels/plugins/types.ts b/src/channels/plugins/types.ts index 9784ab69813..dd02bb33131 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -56,9 +56,11 @@ export type { ChannelLogSink, ChannelMentionAdapter, ChannelMessageActionAdapter, + ChannelMessageActionDiscoveryContext, ChannelMessageActionContext, ChannelMessagingAdapter, ChannelMeta, + ChannelMessageToolSchemaContribution, ChannelOutboundTargetMode, ChannelPollContext, ChannelPollResult, 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 952bf16f51c..f875bb40487 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -112,6 +112,50 @@ describe("runMessageAction plugin dispatch", () => { }), ); }); + + it("routes execution context ids into plugin handleAction", async () => { + await runMessageAction({ + cfg: { + channels: { + feishu: { + enabled: true, + }, + }, + } as OpenClawConfig, + action: "pin", + params: { + channel: "feishu", + messageId: "om_123", + }, + defaultAccountId: "ops", + requesterSenderId: "trusted-user", + sessionKey: "agent:alpha:main", + sessionId: "session-123", + agentId: "alpha", + toolContext: { + currentChannelId: "chat:oc_123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + }, + dryRun: false, + }); + + expect(handleAction).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: "pin", + accountId: "ops", + requesterSenderId: "trusted-user", + sessionKey: "agent:alpha:main", + sessionId: "session-123", + agentId: "alpha", + toolContext: expect.objectContaining({ + currentChannelId: "chat:oc_123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + }), + }), + ); + }); }); describe("media caption behavior", () => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 8480b962544..29afdadbdf3 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -96,6 +96,7 @@ export type RunMessageActionParams = { params: Record; defaultAccountId?: string; requesterSenderId?: string | null; + sessionId?: string; toolContext?: ChannelThreadingToolContext; gateway?: MessageActionRunnerGateway; deps?: OutboundSendDeps; @@ -675,7 +676,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise { - const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx; + const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal, agentId } = ctx; throwIfAborted(abortSignal); const action = input.action as Exclude; if (dryRun) { @@ -701,6 +702,9 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise