From 951f3f992b683689993c1c3055dcb371990f2af7 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:15:58 +0000 Subject: [PATCH 01/31] Plugins: split message discovery and dispatch --- src/agents/channel-tools.ts | 10 +- src/agents/tools/message-tool.ts | 2 +- .../plugins/message-action-discovery.ts | 334 +++++++++++++++- .../plugins/message-action-dispatch.ts | 31 ++ .../plugins/message-actions.security.test.ts | 2 +- src/channels/plugins/message-actions.test.ts | 2 +- src/channels/plugins/message-actions.ts | 370 ------------------ src/infra/outbound/message-action-runner.ts | 2 +- .../outbound/outbound-send-service.test.ts | 2 +- src/infra/outbound/outbound-send-service.ts | 2 +- 10 files changed, 374 insertions(+), 383 deletions(-) create mode 100644 src/channels/plugins/message-action-dispatch.ts delete mode 100644 src/channels/plugins/message-actions.ts diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 8596d3e8471..c94204e8802 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -1,12 +1,10 @@ import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import { createMessageActionDiscoveryContext, - resolveMessageActionDiscoveryChannelId, -} from "../channels/plugins/message-action-discovery.js"; -import { - __testing as messageActionTesting, resolveMessageActionDiscoveryForPlugin, -} from "../channels/plugins/message-actions.js"; + resolveMessageActionDiscoveryChannelId, + __testing as messageActionTesting, +} from "../channels/plugins/message-action-discovery.js"; import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -32,7 +30,7 @@ export function listChannelSupportedActions(params: { return []; } const plugin = getChannelPlugin(channelId as Parameters[0]); - if (!plugin?.actions?.listActions) { + if (!plugin?.actions) { return []; } return resolveMessageActionDiscoveryForPlugin({ diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 7fff178b933..77703d8ee75 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -5,7 +5,7 @@ import { channelSupportsMessageCapabilityForChannel, listChannelMessageActions, resolveChannelMessageToolSchemaProperties, -} from "../../channels/plugins/message-actions.js"; +} from "../../channels/plugins/message-action-discovery.js"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import { CHANNEL_MESSAGE_ACTION_NAMES, diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index 5825219f6dc..d54aec45679 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -1,6 +1,15 @@ +import type { TSchema } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; +import { defaultRuntime } from "../../runtime.js"; import { normalizeAnyChannelId } from "../registry.js"; -import type { ChannelMessageActionDiscoveryContext } from "./types.js"; +import { getChannelPlugin, listChannelPlugins } from "./index.js"; +import type { ChannelMessageCapability } from "./message-capabilities.js"; +import type { + ChannelMessageActionDiscoveryContext, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, +} from "./types.js"; export type ChannelMessageActionDiscoveryInput = { cfg?: OpenClawConfig; @@ -16,6 +25,10 @@ export type ChannelMessageActionDiscoveryInput = { requesterSenderId?: string | null; }; +type ChannelActions = NonNullable>["actions"]>; + +const loggedMessageActionErrors = new Set(); + export function resolveMessageActionDiscoveryChannelId(raw?: string | null): string | undefined { const normalized = normalizeAnyChannelId(raw); if (normalized) { @@ -44,3 +57,322 @@ export function createMessageActionDiscoveryContext( requesterSenderId: params.requesterSenderId, }; } + +function logMessageActionError(params: { + pluginId: string; + operation: "describeMessageTool" | "getCapabilities" | "getToolSchema" | "listActions"; + error: unknown; +}) { + const message = params.error instanceof Error ? params.error.message : String(params.error); + const key = `${params.pluginId}:${params.operation}:${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-action-discovery] ${params.pluginId}.actions.${params.operation} failed: ${stack ?? message}`, + ); +} + +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; + describeMessageTool: NonNullable; +}): ChannelMessageToolDiscovery | null { + try { + return params.describeMessageTool(params.context) ?? null; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "describeMessageTool", + error, + }); + return null; + } +} + +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 + | ChannelMessageToolSchemaContribution[] + | null + | undefined, +): ChannelMessageToolSchemaContribution[] { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +type ResolvedChannelMessageActionDiscovery = { + actions: ChannelMessageActionName[]; + capabilities: readonly ChannelMessageCapability[]; + schemaContributions: ChannelMessageToolSchemaContribution[]; +}; + +export function resolveMessageActionDiscoveryForPlugin(params: { + pluginId: string; + actions?: ChannelActions; + context: ChannelMessageActionDiscoveryContext; + includeActions?: boolean; + includeCapabilities?: boolean; + includeSchema?: boolean; +}): ResolvedChannelMessageActionDiscovery { + const adapter = params.actions; + if (!adapter) { + return { + actions: [], + capabilities: [], + schemaContributions: [], + }; + } + + 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) + : [], + }; + } + + return { + actions: + params.includeActions && adapter.listActions + ? runListActionsSafely({ + pluginId: params.pluginId, + context: params.context, + listActions: adapter.listActions, + }) + : [], + 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, + }), + ) + : [], + }; +} + +export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { + const actions = new Set(["send", "broadcast"]); + for (const plugin of listChannelPlugins()) { + for (const action of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: { cfg }, + includeActions: true, + }).actions) { + actions.add(action); + } + } + return Array.from(actions); +} + +export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { + const capabilities = new Set(); + for (const plugin of listChannelPlugins()) { + for (const capability of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: { cfg }, + includeCapabilities: true, + }).capabilities) { + capabilities.add(capability); + } + } + return Array.from(capabilities); +} + +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[] { + const channelId = resolveMessageActionDiscoveryChannelId(params.channel); + if (!channelId) { + return []; + } + const plugin = getChannelPlugin(channelId as Parameters[0]); + return plugin?.actions + ? Array.from( + resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext(params), + includeCapabilities: true, + }).capabilities, + ) + : []; +} + +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 currentChannel = resolveMessageActionDiscoveryChannelId(params.channel); + const discoveryBase = createMessageActionDiscoveryContext(params); + + for (const plugin of listChannelPlugins()) { + if (!plugin.actions) { + continue; + } + for (const contribution of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: discoveryBase, + includeSchema: true, + }).schemaContributions) { + const visibility = contribution.visibility ?? "current-channel"; + if (currentChannel) { + if (visibility === "all-configured" || plugin.id === currentChannel) { + mergeToolSchemaProperties(properties, contribution.properties); + } + continue; + } + mergeToolSchemaProperties(properties, contribution.properties); + } + } + + return properties; +} + +export function channelSupportsMessageCapability( + cfg: OpenClawConfig, + capability: ChannelMessageCapability, +): boolean { + return listChannelMessageCapabilities(cfg).includes(capability); +} + +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 { + return listChannelMessageCapabilitiesForChannel(params).includes(capability); +} + +export const __testing = { + resetLoggedMessageActionErrors() { + loggedMessageActionErrors.clear(); + }, +}; diff --git a/src/channels/plugins/message-action-dispatch.ts b/src/channels/plugins/message-action-dispatch.ts new file mode 100644 index 00000000000..ab1ddef5235 --- /dev/null +++ b/src/channels/plugins/message-action-dispatch.ts @@ -0,0 +1,31 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { getChannelPlugin } from "./index.js"; +import type { ChannelMessageActionContext } from "./types.js"; + +function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { + const plugin = getChannelPlugin(ctx.channel); + return Boolean( + plugin?.actions?.requiresTrustedRequesterSender?.({ + action: ctx.action, + toolContext: ctx.toolContext, + }), + ); +} + +export async function dispatchChannelMessageAction( + ctx: ChannelMessageActionContext, +): Promise | null> { + if (requiresTrustedRequesterSender(ctx) && !ctx.requesterSenderId?.trim()) { + throw new Error( + `Trusted sender identity is required for ${ctx.channel}:${ctx.action} in tool-driven contexts.`, + ); + } + const plugin = getChannelPlugin(ctx.channel); + if (!plugin?.actions?.handleAction) { + return null; + } + if (plugin.actions.supportsAction && !plugin.actions.supportsAction({ action: ctx.action })) { + return null; + } + return await plugin.actions.handleAction(ctx); +} diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index b8b62afdecd..ed178a9e2fa 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -6,7 +6,7 @@ import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; -import { dispatchChannelMessageAction } from "./message-actions.js"; +import { dispatchChannelMessageAction } from "./message-action-dispatch.js"; import type { ChannelPlugin } from "./types.js"; const handleAction = vi.fn(async () => jsonResult({ ok: true })); diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 13a28e098db..396b82a498c 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -15,7 +15,7 @@ import { listChannelMessageCapabilities, listChannelMessageCapabilitiesForChannel, resolveChannelMessageToolSchemaProperties, -} from "./message-actions.js"; +} from "./message-action-discovery.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; import type { ChannelPlugin } from "./types.js"; diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts deleted file mode 100644 index 8bf765ea81f..00000000000 --- a/src/channels/plugins/message-actions.ts +++ /dev/null @@ -1,370 +0,0 @@ -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 { - createMessageActionDiscoveryContext, - resolveMessageActionDiscoveryChannelId, -} from "./message-action-discovery.js"; -import type { ChannelMessageCapability } from "./message-capabilities.js"; -import type { - ChannelMessageActionContext, - ChannelMessageActionDiscoveryContext, - ChannelMessageActionName, - ChannelMessageToolDiscovery, - ChannelMessageToolSchemaContribution, -} from "./types.js"; - -type ChannelActions = NonNullable>["actions"]>; - -function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { - const plugin = getChannelPlugin(ctx.channel); - return Boolean( - plugin?.actions?.requiresTrustedRequesterSender?.({ - action: ctx.action, - toolContext: ctx.toolContext, - }), - ); -} - -const loggedMessageActionErrors = new Set(); - -function logMessageActionError(params: { - pluginId: string; - operation: "describeMessageTool" | "getCapabilities" | "getToolSchema" | "listActions"; - error: unknown; -}) { - const message = params.error instanceof Error ? params.error.message : String(params.error); - const key = `${params.pluginId}:${params.operation}:${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.${params.operation} failed: ${stack ?? message}`, - ); -} - -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; - describeMessageTool: NonNullable; -}): ChannelMessageToolDiscovery | null { - try { - return params.describeMessageTool(params.context) ?? null; - } catch (error) { - logMessageActionError({ - pluginId: params.pluginId, - operation: "describeMessageTool", - error, - }); - return null; - } -} - -function listCapabilities(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 normalizeToolSchemaContributions( - value: - | ChannelMessageToolSchemaContribution - | ChannelMessageToolSchemaContribution[] - | null - | undefined, -): ChannelMessageToolSchemaContribution[] { - if (!value) { - return []; - } - return Array.isArray(value) ? value : [value]; -} - -type ResolvedChannelMessageActionDiscovery = { - actions: ChannelMessageActionName[]; - capabilities: readonly ChannelMessageCapability[]; - schemaContributions: ChannelMessageToolSchemaContribution[]; -}; - -export function resolveMessageActionDiscoveryForPlugin(params: { - pluginId: string; - actions?: ChannelActions; - context: ChannelMessageActionDiscoveryContext; - includeActions?: boolean; - includeCapabilities?: boolean; - includeSchema?: boolean; -}): ResolvedChannelMessageActionDiscovery { - const adapter = params.actions; - if (!adapter) { - return { - actions: [], - capabilities: [], - schemaContributions: [], - }; - } - - 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) - : [], - }; - } - - return { - actions: - params.includeActions && adapter.listActions - ? runListActionsSafely({ - pluginId: params.pluginId, - context: params.context, - listActions: adapter.listActions, - }) - : [], - capabilities: - params.includeCapabilities && adapter.getCapabilities - ? listCapabilities({ - pluginId: params.pluginId, - actions: adapter, - context: params.context, - }) - : [], - schemaContributions: - params.includeSchema && adapter.getToolSchema - ? normalizeToolSchemaContributions( - runGetToolSchemaSafely({ - pluginId: params.pluginId, - context: params.context, - getToolSchema: adapter.getToolSchema, - }), - ) - : [], - }; -} - -export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { - const actions = new Set(["send", "broadcast"]); - for (const plugin of listChannelPlugins()) { - for (const action of resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, - context: { cfg }, - includeActions: true, - }).actions) { - actions.add(action); - } - } - return Array.from(actions); -} - -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; - } -} - -export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { - const capabilities = new Set(); - for (const plugin of listChannelPlugins()) { - for (const capability of resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, - context: { cfg }, - includeCapabilities: true, - }).capabilities) { - capabilities.add(capability); - } - } - return Array.from(capabilities); -} - -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[] { - const channelId = resolveMessageActionDiscoveryChannelId(params.channel); - if (!channelId) { - return []; - } - const plugin = getChannelPlugin(channelId as Parameters[0]); - return plugin?.actions - ? Array.from( - resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, - context: createMessageActionDiscoveryContext(params), - includeCapabilities: true, - }).capabilities, - ) - : []; -} - -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 = resolveMessageActionDiscoveryChannelId(params.channel); - const discoveryBase: ChannelMessageActionDiscoveryContext = - createMessageActionDiscoveryContext(params); - - for (const plugin of plugins) { - if (!plugin?.actions) { - continue; - } - for (const contribution of resolveMessageActionDiscoveryForPlugin({ - pluginId: plugin.id, - actions: plugin.actions, - context: discoveryBase, - includeSchema: true, - }).schemaContributions) { - const visibility = contribution.visibility ?? "current-channel"; - if (currentChannel) { - if (visibility === "all-configured" || plugin.id === currentChannel) { - mergeToolSchemaProperties(properties, contribution.properties); - } - continue; - } - mergeToolSchemaProperties(properties, contribution.properties); - } - } - - return properties; -} - -export function channelSupportsMessageCapability( - cfg: OpenClawConfig, - capability: ChannelMessageCapability, -): boolean { - return listChannelMessageCapabilities(cfg).includes(capability); -} - -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 { - return listChannelMessageCapabilitiesForChannel(params).includes(capability); -} - -export async function dispatchChannelMessageAction( - ctx: ChannelMessageActionContext, -): Promise | null> { - if (requiresTrustedRequesterSender(ctx) && !ctx.requesterSenderId?.trim()) { - throw new Error( - `Trusted sender identity is required for ${ctx.channel}:${ctx.action} in tool-driven contexts.`, - ); - } - const plugin = getChannelPlugin(ctx.channel); - if (!plugin?.actions?.handleAction) { - return null; - } - if (plugin.actions.supportsAction && !plugin.actions.supportsAction({ action: ctx.action })) { - return null; - } - return await plugin.actions.handleAction(ctx); -} - -export const __testing = { - resetLoggedMessageActionErrors() { - loggedMessageActionErrors.clear(); - }, -}; diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 29afdadbdf3..70646a288a2 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -7,7 +7,7 @@ import { } from "../../agents/tools/common.js"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; -import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; +import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; import type { ChannelId, ChannelMessageActionName, diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index f5d1f2b9b28..3f3fd0f2fcc 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), })); -vi.mock("../../channels/plugins/message-actions.js", () => ({ +vi.mock("../../channels/plugins/message-action-dispatch.js", () => ({ dispatchChannelMessageAction: mocks.dispatchChannelMessageAction, })); diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index c583a1ace91..5d518798afa 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -1,5 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; +import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js"; From 7dabcf287db205795a7ed5f15a8ea84968d841c1 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:16:02 +0000 Subject: [PATCH 02/31] Agents: align compact message discovery scope --- src/agents/pi-embedded-runner/compact.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 7893f51b70c..66320fb0b21 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -113,6 +113,10 @@ export type CompactEmbeddedPiSessionParams = { messageChannel?: string; messageProvider?: string; agentAccountId?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + requesterSenderId?: string; authProfileId?: string; /** Group id for channel-level tool policy resolution. */ groupId?: string | null; @@ -658,10 +662,14 @@ export async function compactEmbeddedPiSessionDirect( ? 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, + requesterSenderId: params.requesterSenderId, }) : undefined; const messageToolHints = runtimeChannel From ab1da26f4dbda2c35a2b6ae1066511fcd5f2b97e Mon Sep 17 00:00:00 2001 From: Brian Ernesto Date: Tue, 17 Mar 2026 18:29:11 -0600 Subject: [PATCH 03/31] fix(macos): show sessions after controls in tray menu (#38079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macos): show sessions after controls in tray menu When many sessions are active, the injected session rows push the toggles, action buttons, and settings items off-screen, requiring a scroll to reach them. Change findInsertIndex and findNodesInsertIndex to anchor just before the separator above 'Settings…' instead of before 'Send Heartbeats'. This ensures the controls section is always immediately visible on menu open, with sessions appearing below. * refactor: extract findAnchoredInsertIndex to eliminate duplication findInsertIndex and findNodesInsertIndex shared identical logic. Extract into a single private helper so any future anchor change (e.g. Settings item title) only needs one edit. * macOS: use structural tray menu anchor --------- Co-authored-by: Brian Ernesto Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com> --- CHANGELOG.md | 1 + .../OpenClaw/MenuSessionsInjector.swift | 47 ++++++++++--------- .../MenuSessionsInjectorTests.swift | 35 +++++++++++++- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 662d3fc6d13..4592c1ae307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -647,6 +647,7 @@ Docs: https://docs.openclaw.ai - Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev. - Web UI/config form: treat `additionalProperties: true` object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai. - Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune. +- macOS/tray menu: keep injected sessions and device rows below the controls section so toggles and action buttons stay visible even when many sessions are active. (#38079) Thanks @bernesto. - Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so `requireMention` checks compare against current bot identity instead of stale config names, fixing missed `@bot` handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai. - Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd. - Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd. diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index eb6271d0a8c..9f667cc6239 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -1099,38 +1099,33 @@ extension MenuSessionsInjector { // MARK: - Width + placement private func findInsertIndex(in menu: NSMenu) -> Int? { - // Insert right before the separator above "Send Heartbeats". - if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { - if let sepIdx = menu.items[..= 1 { return 1 } - return menu.items.count + self.findDynamicSectionInsertIndex(in: menu) } private func findNodesInsertIndex(in menu: NSMenu) -> Int? { - if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { - if let sepIdx = menu.items[.. Int? { + // Keep controls and action buttons visible by inserting dynamic rows at the + // built-in footer boundary, not by matching localized menu item titles. + if let footerSeparatorIndex = menu.items.lastIndex(where: { item in + item.isSeparatorItem && !self.isInjectedItem(item) + }) { + return footerSeparatorIndex } - if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) { - return sepIdx + if let firstBaseItemIndex = menu.items.firstIndex(where: { !self.isInjectedItem($0) }) { + return min(firstBaseItemIndex + 1, menu.items.count) } - if menu.items.count >= 1 { return 1 } return menu.items.count } + private func isInjectedItem(_ item: NSMenuItem) -> Bool { + item.tag == self.tag || item.tag == self.nodesTag + } + private func initialWidth(for menu: NSMenu) -> CGFloat { if let openWidth = self.menuOpenWidth { return max(300, openWidth) @@ -1236,5 +1231,13 @@ extension MenuSessionsInjector { func injectForTesting(into menu: NSMenu) { self.inject(into: menu) } + + func testingFindInsertIndex(in menu: NSMenu) -> Int? { + self.findInsertIndex(in: menu) + } + + func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? { + self.findNodesInsertIndex(in: menu) + } } #endif diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift index 186675f1eea..b1d01b9650e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -5,7 +5,26 @@ import Testing @Suite(.serialized) @MainActor struct MenuSessionsInjectorTests { - @Test func `injects disconnected message`() { + @Test func anchorsDynamicRowsBelowControlsAndActions() throws { + let injector = MenuSessionsInjector() + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Open Chat", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: "")) + + let footerSeparatorIndex = try #require(menu.items.lastIndex(where: { $0.isSeparatorItem })) + #expect(injector.testingFindInsertIndex(in: menu) == footerSeparatorIndex) + #expect(injector.testingFindNodesInsertIndex(in: menu) == footerSeparatorIndex) + } + + @Test func injectsDisconnectedMessage() { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(false) injector.setTestingSnapshot(nil, errorText: nil) @@ -19,7 +38,7 @@ struct MenuSessionsInjectorTests { #expect(menu.items.contains { $0.tag == 9_415_557 }) } - @Test func `injects session rows`() { + @Test func injectsSessionRows() throws { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(true) @@ -88,10 +107,22 @@ struct MenuSessionsInjectorTests { menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: "")) injector.injectForTesting(into: menu) #expect(menu.items.contains { $0.tag == 9_415_557 }) #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) + let sendHeartbeatsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Send Heartbeats" })) + let openDashboardIndex = try #require(menu.items.firstIndex(where: { $0.title == "Open Dashboard" })) + let firstInjectedIndex = try #require(menu.items.firstIndex(where: { $0.tag == 9_415_557 })) + let settingsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Settings…" })) + #expect(sendHeartbeatsIndex < firstInjectedIndex) + #expect(openDashboardIndex < firstInjectedIndex) + #expect(firstInjectedIndex < settingsIndex) } @Test func `cost usage submenu does not use injector delegate`() { From ab62f3b9f411b742a873485eef0969a2fdef6a1c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:32:51 +0000 Subject: [PATCH 04/31] Agents: route embedded discovery and compaction ids --- src/agents/pi-embedded-runner/compact.ts | 30 ++++---- .../compaction-runtime-context.test.ts | 77 +++++++++++++++++++ .../compaction-runtime-context.ts | 76 ++++++++++++++++++ .../message-action-discovery-input.test.ts | 58 ++++++++++++++ .../message-action-discovery-input.ts | 27 +++++++ src/agents/pi-embedded-runner/run.ts | 41 ++++++---- .../pi-embedded-runner/run/attempt.test.ts | 34 ++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 41 ++++++---- 8 files changed, 340 insertions(+), 44 deletions(-) create mode 100644 src/agents/pi-embedded-runner/compaction-runtime-context.test.ts create mode 100644 src/agents/pi-embedded-runner/compaction-runtime-context.ts create mode 100644 src/agents/pi-embedded-runner/message-action-discovery-input.test.ts create mode 100644 src/agents/pi-embedded-runner/message-action-discovery-input.ts diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 66320fb0b21..8c46de5c165 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -91,6 +91,7 @@ import { import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; +import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-discovery-input.js"; import { buildModelAliasLines, resolveModelAsync } from "./model.js"; import { buildEmbeddedSandboxInfo } from "./sandbox-info.js"; import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js"; @@ -116,7 +117,8 @@ export type CompactEmbeddedPiSessionParams = { currentChannelId?: string; currentThreadTs?: string; currentMessageId?: string | number; - requesterSenderId?: string; + /** Trusted sender id from inbound context for scoped message-tool discovery. */ + senderId?: string; authProfileId?: string; /** Group id for channel-level tool policy resolution. */ groupId?: string | null; @@ -659,18 +661,20 @@ export async function compactEmbeddedPiSessionDirect( }); // Resolve channel-specific message actions for system prompt const channelActions = runtimeChannel - ? 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, - requesterSenderId: params.requesterSenderId, - }) + ? listChannelSupportedActions( + buildEmbeddedMessageActionDiscoveryInput({ + 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, + senderId: params.senderId, + }), + ) : undefined; const messageToolHints = runtimeChannel ? resolveChannelMessageToolHints({ diff --git a/src/agents/pi-embedded-runner/compaction-runtime-context.test.ts b/src/agents/pi-embedded-runner/compaction-runtime-context.test.ts new file mode 100644 index 00000000000..9c87bfc6aaf --- /dev/null +++ b/src/agents/pi-embedded-runner/compaction-runtime-context.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js"; + +describe("buildEmbeddedCompactionRuntimeContext", () => { + it("preserves sender and current message routing for compaction", () => { + expect( + buildEmbeddedCompactionRuntimeContext({ + sessionKey: "agent:main:thread:1", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + authProfileId: "openai:p1", + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + config: {} as OpenClawConfig, + senderIsOwner: true, + senderId: "user-123", + provider: "openai-codex", + modelId: "gpt-5.3-codex", + thinkLevel: "off", + reasoningLevel: "on", + extraSystemPrompt: "extra", + ownerNumbers: ["+15555550123"], + }), + ).toMatchObject({ + sessionKey: "agent:main:thread:1", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + authProfileId: "openai:p1", + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + senderId: "user-123", + provider: "openai-codex", + model: "gpt-5.3-codex", + }); + }); + + it("normalizes nullable compaction routing fields to undefined", () => { + expect( + buildEmbeddedCompactionRuntimeContext({ + sessionKey: null, + messageChannel: null, + messageProvider: null, + agentAccountId: null, + currentChannelId: null, + currentThreadTs: null, + currentMessageId: null, + authProfileId: null, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + senderId: null, + provider: null, + modelId: null, + }), + ).toMatchObject({ + sessionKey: undefined, + messageChannel: undefined, + messageProvider: undefined, + agentAccountId: undefined, + currentChannelId: undefined, + currentThreadTs: undefined, + currentMessageId: undefined, + authProfileId: undefined, + senderId: undefined, + provider: undefined, + model: undefined, + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/compaction-runtime-context.ts b/src/agents/pi-embedded-runner/compaction-runtime-context.ts new file mode 100644 index 00000000000..5f64089f63b --- /dev/null +++ b/src/agents/pi-embedded-runner/compaction-runtime-context.ts @@ -0,0 +1,76 @@ +import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ExecElevatedDefaults } from "../bash-tools.js"; +import type { SkillSnapshot } from "../skills.js"; + +export type EmbeddedCompactionRuntimeContext = { + sessionKey?: string; + messageChannel?: string; + messageProvider?: string; + agentAccountId?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + authProfileId?: string; + workspaceDir: string; + agentDir: string; + config?: OpenClawConfig; + skillsSnapshot?: SkillSnapshot; + senderIsOwner?: boolean; + senderId?: string; + provider?: string; + model?: string; + thinkLevel?: ThinkLevel; + reasoningLevel?: ReasoningLevel; + bashElevated?: ExecElevatedDefaults; + extraSystemPrompt?: string; + ownerNumbers?: string[]; +}; + +export function buildEmbeddedCompactionRuntimeContext(params: { + sessionKey?: string | null; + messageChannel?: string | null; + messageProvider?: string | null; + agentAccountId?: string | null; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + authProfileId?: string | null; + workspaceDir: string; + agentDir: string; + config?: OpenClawConfig; + skillsSnapshot?: SkillSnapshot; + senderIsOwner?: boolean; + senderId?: string | null; + provider?: string | null; + modelId?: string | null; + thinkLevel?: ThinkLevel; + reasoningLevel?: ReasoningLevel; + bashElevated?: ExecElevatedDefaults; + extraSystemPrompt?: string; + ownerNumbers?: string[]; +}): EmbeddedCompactionRuntimeContext { + return { + sessionKey: params.sessionKey ?? undefined, + messageChannel: params.messageChannel ?? undefined, + messageProvider: params.messageProvider ?? undefined, + agentAccountId: params.agentAccountId ?? undefined, + currentChannelId: params.currentChannelId ?? undefined, + currentThreadTs: params.currentThreadTs ?? undefined, + currentMessageId: params.currentMessageId ?? undefined, + authProfileId: params.authProfileId ?? undefined, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + config: params.config, + skillsSnapshot: params.skillsSnapshot, + senderIsOwner: params.senderIsOwner, + senderId: params.senderId ?? undefined, + provider: params.provider ?? undefined, + model: params.modelId ?? undefined, + thinkLevel: params.thinkLevel, + reasoningLevel: params.reasoningLevel, + bashElevated: params.bashElevated, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + }; +} diff --git a/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts b/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts new file mode 100644 index 00000000000..7b2acd199c0 --- /dev/null +++ b/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-discovery-input.js"; + +describe("buildEmbeddedMessageActionDiscoveryInput", () => { + it("maps sender and routing scope into message-action discovery context", () => { + expect( + buildEmbeddedMessageActionDiscoveryInput({ + channel: "telegram", + currentChannelId: "chat-1", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + accountId: "acct-1", + sessionKey: "agent:main:thread:1", + sessionId: "session-1", + agentId: "main", + senderId: "user-123", + }), + ).toEqual({ + cfg: undefined, + channel: "telegram", + currentChannelId: "chat-1", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + accountId: "acct-1", + sessionKey: "agent:main:thread:1", + sessionId: "session-1", + agentId: "main", + requesterSenderId: "user-123", + }); + }); + + it("normalizes nullable routing fields to undefined", () => { + expect( + buildEmbeddedMessageActionDiscoveryInput({ + channel: "slack", + currentChannelId: null, + currentThreadTs: null, + currentMessageId: null, + accountId: null, + sessionKey: null, + sessionId: null, + agentId: null, + senderId: null, + }), + ).toEqual({ + cfg: undefined, + channel: "slack", + currentChannelId: undefined, + currentThreadTs: undefined, + currentMessageId: undefined, + accountId: undefined, + sessionKey: undefined, + sessionId: undefined, + agentId: undefined, + requesterSenderId: undefined, + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/message-action-discovery-input.ts b/src/agents/pi-embedded-runner/message-action-discovery-input.ts new file mode 100644 index 00000000000..3002e90d357 --- /dev/null +++ b/src/agents/pi-embedded-runner/message-action-discovery-input.ts @@ -0,0 +1,27 @@ +import type { OpenClawConfig } from "../../config/config.js"; + +export function buildEmbeddedMessageActionDiscoveryInput(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; + senderId?: string | null; +}) { + return { + cfg: params.cfg, + channel: params.channel, + currentChannelId: params.currentChannelId ?? undefined, + currentThreadTs: params.currentThreadTs ?? undefined, + currentMessageId: params.currentMessageId ?? undefined, + accountId: params.accountId ?? undefined, + sessionKey: params.sessionKey ?? undefined, + sessionId: params.sessionId ?? undefined, + agentId: params.agentId ?? undefined, + requesterSenderId: params.senderId ?? undefined, + }; +} diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 3f41357f0e5..a35c03d98ca 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -65,6 +65,7 @@ import { import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; +import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; import { resolveModelAsync } from "./model.js"; @@ -1141,24 +1142,30 @@ export async function runEmbeddedPiAgent( force: true, compactionTarget: "budget", runtimeContext: { - sessionKey: params.sessionKey, - messageChannel: params.messageChannel, - messageProvider: params.messageProvider, - agentAccountId: params.agentAccountId, - authProfileId: lastProfileId, - workspaceDir: resolvedWorkspace, - agentDir, - config: params.config, - skillsSnapshot: params.skillsSnapshot, - senderIsOwner: params.senderIsOwner, - provider, - model: modelId, + ...buildEmbeddedCompactionRuntimeContext({ + sessionKey: params.sessionKey, + messageChannel: params.messageChannel, + messageProvider: params.messageProvider, + agentAccountId: params.agentAccountId, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + authProfileId: lastProfileId, + workspaceDir: resolvedWorkspace, + agentDir, + config: params.config, + skillsSnapshot: params.skillsSnapshot, + senderIsOwner: params.senderIsOwner, + senderId: params.senderId, + provider, + modelId, + thinkLevel, + reasoningLevel: params.reasoningLevel, + bashElevated: params.bashElevated, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + }), runId: params.runId, - thinkLevel, - reasoningLevel: params.reasoningLevel, - bashElevated: params.bashElevated, - extraSystemPrompt: params.extraSystemPrompt, - ownerNumbers: params.ownerNumbers, trigger: "overflow", ...(observedOverflowTokens !== undefined ? { currentTokenCount: observedOverflowTokens } diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index d18966af421..20bf752587b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1249,4 +1249,38 @@ describe("buildAfterTurnRuntimeContext", () => { agentDir: "/tmp/agent", }); }); + + it("preserves sender and channel routing context for scoped compaction discovery", () => { + const legacy = buildAfterTurnRuntimeContext({ + attempt: { + sessionKey: "agent:main:session:abc", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + authProfileId: "openai:p1", + config: {} as OpenClawConfig, + skillsSnapshot: undefined, + senderIsOwner: true, + senderId: "user-123", + provider: "openai-codex", + modelId: "gpt-5.3-codex", + thinkLevel: "off", + reasoningLevel: "on", + extraSystemPrompt: "extra", + ownerNumbers: ["+15555550123"], + }, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + }); + + expect(legacy).toMatchObject({ + senderId: "user-123", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + }); + }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 0fa03797a60..9a46beca5d2 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -101,6 +101,7 @@ import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js"; import type { CompactEmbeddedPiSessionParams } from "../compact.js"; +import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js"; import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js"; import { buildEmbeddedExtensionFactories } from "../extensions.js"; import { applyExtraParamsToAgent } from "../extra-params.js"; @@ -111,6 +112,7 @@ import { } from "../google.js"; import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js"; import { log } from "../logger.js"; +import { buildEmbeddedMessageActionDiscoveryInput } from "../message-action-discovery-input.js"; import { buildModelAliasLines } from "../model.js"; import { clearActiveEmbeddedRun, @@ -1280,9 +1282,13 @@ export function buildAfterTurnRuntimeContext(params: { | "messageChannel" | "messageProvider" | "agentAccountId" + | "currentChannelId" + | "currentThreadTs" + | "currentMessageId" | "config" | "skillsSnapshot" | "senderIsOwner" + | "senderId" | "provider" | "modelId" | "thinkLevel" @@ -1295,25 +1301,29 @@ export function buildAfterTurnRuntimeContext(params: { workspaceDir: string; agentDir: string; }): Partial { - return { + return buildEmbeddedCompactionRuntimeContext({ sessionKey: params.attempt.sessionKey, messageChannel: params.attempt.messageChannel, messageProvider: params.attempt.messageProvider, agentAccountId: params.attempt.agentAccountId, + currentChannelId: params.attempt.currentChannelId, + currentThreadTs: params.attempt.currentThreadTs, + currentMessageId: params.attempt.currentMessageId, authProfileId: params.attempt.authProfileId, workspaceDir: params.workspaceDir, agentDir: params.agentDir, config: params.attempt.config, skillsSnapshot: params.attempt.skillsSnapshot, senderIsOwner: params.attempt.senderIsOwner, + senderId: params.attempt.senderId, provider: params.attempt.provider, - model: params.attempt.modelId, + modelId: params.attempt.modelId, thinkLevel: params.attempt.thinkLevel, reasoningLevel: params.attempt.reasoningLevel, bashElevated: params.attempt.bashElevated, extraSystemPrompt: params.attempt.extraSystemPrompt, ownerNumbers: params.attempt.ownerNumbers, - }; + }); } function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } { @@ -1620,17 +1630,20 @@ export async function runEmbeddedAttempt( const reasoningTagHint = isReasoningTagProvider(params.provider); // Resolve channel-specific message actions for system prompt const channelActions = runtimeChannel - ? 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, - }) + ? listChannelSupportedActions( + buildEmbeddedMessageActionDiscoveryInput({ + 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, + senderId: params.senderId, + }), + ) : undefined; const messageToolHints = runtimeChannel ? resolveChannelMessageToolHints({ From f2de673130ad2cd159e3569a90c7f434bd0b366d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:47:46 +0000 Subject: [PATCH 05/31] Docs: clarify plugin-owned message discovery --- docs/help/testing.md | 11 +++++++++++ docs/tools/plugin.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/docs/help/testing.md b/docs/help/testing.md index 2055db4373f..0d14f507bc9 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -52,6 +52,17 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Runs in CI - No real keys required - Should be fast and stable +- Embedded runner note: + - When you change message-tool discovery inputs or compaction runtime context, + keep both levels of coverage. + - Add focused helper regressions for pure routing/normalization seams. + - Also keep the embedded runner integration suites healthy: + `src/agents/pi-embedded-runner/compact.hooks.test.ts`, + `src/agents/pi-embedded-runner/run.overflow-compaction.test.ts`, and + `src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts`. + - Those suites verify that scoped ids and compaction behavior still flow + through the real `run.ts` / `compact.ts` paths; helper-only tests are not a + sufficient substitute for those seams. - Pool note: - OpenClaw uses Vitest `vmForks` on Node 22, 23, and 24 for faster unit shards. - On Node 25+, OpenClaw automatically falls back to regular `forks` until the repo is re-validated there. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 27979dcb125..af4cd1bf6ac 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -188,6 +188,46 @@ The important design boundary: That split lets OpenClaw validate config, explain missing/disabled plugins, and build UI/schema hints before the full runtime is active. +### Channel plugins and the shared message tool + +Channel plugins do not need to register a separate send/edit/react tool for +normal chat actions. OpenClaw keeps one shared `message` tool in core, and +channel plugins own the channel-specific discovery and execution behind it. + +The current boundary is: + +- core owns the shared `message` tool host, prompt wiring, session/thread + bookkeeping, and execution dispatch +- channel plugins own scoped action discovery, capability discovery, and any + channel-specific schema fragments +- channel plugins execute the final action through their action adapter + +For channel plugins, the preferred SDK surface is +`ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery +call lets a plugin return its visible actions, capabilities, and schema +contributions together so those pieces do not drift apart. + +Core passes runtime scope into that discovery step. Important fields include: + +- `accountId` +- `currentChannelId` +- `currentThreadTs` +- `currentMessageId` +- `sessionKey` +- `sessionId` +- `agentId` +- trusted inbound `requesterSenderId` + +That matters for context-sensitive plugins. A channel can hide or expose +message actions based on the active account, current room/thread/message, or +trusted requester identity without hardcoding channel-specific branches in the +core `message` tool. + +This is why embedded-runner routing changes are still plugin work: the runner is +responsible for forwarding the current chat/session identity into the plugin +discovery boundary so the shared `message` tool exposes the right channel-owned +surface for the current turn. + ## Capability ownership model OpenClaw treats a native plugin as the ownership boundary for a **company** or a From 53df7ff86d19100b7cab90c7dddcc2b94bce5269 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 01:06:43 +0000 Subject: [PATCH 06/31] Agents: stabilize overflow runner test harness --- .../run.overflow-compaction.harness.ts | 406 ++++++++++++++++++ .../run.overflow-compaction.loop.test.ts | 55 +-- .../run.overflow-compaction.mocks.shared.ts | 292 ------------- .../run.overflow-compaction.shared-test.ts | 30 -- .../run.overflow-compaction.test.ts | 30 +- .../sessions-yield.orchestration.test.ts | 17 +- .../usage-reporting.test.ts | 32 +- 7 files changed, 479 insertions(+), 383 deletions(-) create mode 100644 src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts delete mode 100644 src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts delete mode 100644 src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts new file mode 100644 index 00000000000..9e7853ef7d5 --- /dev/null +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts @@ -0,0 +1,406 @@ +import { vi, type Mock } from "vitest"; +import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import type { + PluginHookAgentContext, + PluginHookBeforeAgentStartResult, + PluginHookBeforeModelResolveResult, + PluginHookBeforePromptBuildResult, +} from "../../plugins/types.js"; +import type { EmbeddedRunAttemptResult } from "./run/types.js"; + +type MockCompactionResult = + | { + ok: true; + compacted: true; + result: { + summary: string; + firstKeptEntryId?: string; + tokensBefore?: number; + tokensAfter?: number; + }; + reason?: string; + } + | { + ok: false; + compacted: false; + reason: string; + result?: undefined; + }; + +export const mockedGlobalHookRunner = { + hasHooks: vi.fn((_hookName: string) => false), + runBeforeAgentStart: vi.fn( + async ( + _event: { prompt: string; messages?: unknown[] }, + _ctx: PluginHookAgentContext, + ): Promise => undefined, + ), + runBeforePromptBuild: vi.fn( + async ( + _event: { prompt: string; messages: unknown[] }, + _ctx: PluginHookAgentContext, + ): Promise => undefined, + ), + runBeforeModelResolve: vi.fn( + async ( + _event: { prompt: string }, + _ctx: PluginHookAgentContext, + ): Promise => undefined, + ), + runBeforeCompaction: vi.fn(async () => undefined), + runAfterCompaction: vi.fn(async () => undefined), +}; + +export const mockedContextEngine = { + info: { ownsCompaction: false as boolean }, + compact: vi.fn<(params: unknown) => Promise>(async () => ({ + ok: false as const, + compacted: false as const, + reason: "nothing to compact", + })), +}; + +export const mockedContextEngineCompact = mockedContextEngine.compact; +export const mockedCompactDirect = mockedContextEngine.compact; +export const mockedEnsureRuntimePluginsLoaded = vi.fn<(params?: unknown) => void>(); +export const mockedPrepareProviderRuntimeAuth = vi.fn(async () => undefined); +export const mockedRunEmbeddedAttempt = + vi.fn<(params: unknown) => Promise>(); +export const mockedSessionLikelyHasOversizedToolResults = vi.fn(() => false); +export const mockedTruncateOversizedToolResultsInSession = vi.fn< + () => Promise +>(async () => ({ + truncated: false, + truncatedCount: 0, + reason: "no oversized tool results", +})); + +type MockFailoverErrorDescription = { + message: string; + reason: string | undefined; + status: number | undefined; + code: string | undefined; +}; + +type MockCoerceToFailoverError = ( + err: unknown, + params?: { provider?: string; model?: string; profileId?: string }, +) => unknown; +type MockDescribeFailoverError = (err: unknown) => MockFailoverErrorDescription; +type MockResolveFailoverStatus = (reason: string) => number | undefined; +type MockTruncateOversizedToolResultsResult = { + truncated: boolean; + truncatedCount: number; + reason?: string; +}; + +export const mockedCoerceToFailoverError = vi.fn(); +export const mockedDescribeFailoverError = vi.fn( + (err: unknown): MockFailoverErrorDescription => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + }), +); +export const mockedResolveFailoverStatus = vi.fn(); + +export const mockedLog: { + debug: Mock<(...args: unknown[]) => void>; + info: Mock<(...args: unknown[]) => void>; + warn: Mock<(...args: unknown[]) => void>; + error: Mock<(...args: unknown[]) => void>; + isEnabled: Mock<(level?: string) => boolean>; +} = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + isEnabled: vi.fn(() => false), +}; + +export const mockedClassifyFailoverReason = vi.fn(() => null); +export const mockedExtractObservedOverflowTokenCount = vi.fn((msg?: string) => { + const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i); + return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined; +}); +export const mockedIsCompactionFailureError = vi.fn(() => false); +export const mockedIsLikelyContextOverflowError = vi.fn((msg?: string) => { + const lower = (msg ?? "").toLowerCase(); + return ( + lower.includes("request_too_large") || + lower.includes("context window exceeded") || + lower.includes("prompt is too long") + ); +}); +export const mockedPickFallbackThinkingLevel = vi.fn<(params?: unknown) => ThinkLevel | null>( + () => null, +); + +export const overflowBaseRunParams = { + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp/workspace", + prompt: "hello", + timeoutMs: 30000, + runId: "run-1", +} as const; + +export function resetRunOverflowCompactionHarnessMocks(): void { + mockedGlobalHookRunner.hasHooks.mockReset(); + mockedGlobalHookRunner.hasHooks.mockReturnValue(false); + mockedGlobalHookRunner.runBeforeAgentStart.mockReset(); + mockedGlobalHookRunner.runBeforeAgentStart.mockResolvedValue(undefined); + mockedGlobalHookRunner.runBeforePromptBuild.mockReset(); + mockedGlobalHookRunner.runBeforePromptBuild.mockResolvedValue(undefined); + mockedGlobalHookRunner.runBeforeModelResolve.mockReset(); + mockedGlobalHookRunner.runBeforeModelResolve.mockResolvedValue(undefined); + mockedGlobalHookRunner.runBeforeCompaction.mockReset(); + mockedGlobalHookRunner.runBeforeCompaction.mockResolvedValue(undefined); + mockedGlobalHookRunner.runAfterCompaction.mockReset(); + mockedGlobalHookRunner.runAfterCompaction.mockResolvedValue(undefined); + + mockedContextEngine.info.ownsCompaction = false; + mockedContextEngineCompact.mockReset(); + mockedContextEngineCompact.mockResolvedValue({ + ok: false, + compacted: false, + reason: "nothing to compact", + }); + + mockedEnsureRuntimePluginsLoaded.mockReset(); + mockedPrepareProviderRuntimeAuth.mockReset(); + mockedPrepareProviderRuntimeAuth.mockResolvedValue(undefined); + mockedRunEmbeddedAttempt.mockReset(); + mockedSessionLikelyHasOversizedToolResults.mockReset(); + mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false); + mockedTruncateOversizedToolResultsInSession.mockReset(); + mockedTruncateOversizedToolResultsInSession.mockResolvedValue({ + truncated: false, + truncatedCount: 0, + reason: "no oversized tool results", + }); + + mockedCoerceToFailoverError.mockReset(); + mockedCoerceToFailoverError.mockReturnValue(null); + mockedDescribeFailoverError.mockReset(); + mockedDescribeFailoverError.mockImplementation( + (err: unknown): MockFailoverErrorDescription => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + }), + ); + mockedResolveFailoverStatus.mockReset(); + mockedResolveFailoverStatus.mockReturnValue(undefined); + + mockedLog.debug.mockReset(); + mockedLog.info.mockReset(); + mockedLog.warn.mockReset(); + mockedLog.error.mockReset(); + mockedLog.isEnabled.mockReset(); + mockedLog.isEnabled.mockReturnValue(false); + + mockedClassifyFailoverReason.mockReset(); + mockedClassifyFailoverReason.mockReturnValue(null); + mockedExtractObservedOverflowTokenCount.mockReset(); + mockedExtractObservedOverflowTokenCount.mockImplementation((msg?: string) => { + const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i); + return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined; + }); + mockedIsCompactionFailureError.mockReset(); + mockedIsCompactionFailureError.mockReturnValue(false); + mockedIsLikelyContextOverflowError.mockReset(); + mockedIsLikelyContextOverflowError.mockImplementation((msg?: string) => { + const lower = (msg ?? "").toLowerCase(); + return ( + lower.includes("request_too_large") || + lower.includes("context window exceeded") || + lower.includes("prompt is too long") + ); + }); + mockedPickFallbackThinkingLevel.mockReset(); + mockedPickFallbackThinkingLevel.mockReturnValue(null); +} + +export async function loadRunOverflowCompactionHarness(): Promise<{ + runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +}> { + resetRunOverflowCompactionHarnessMocks(); + vi.resetModules(); + + vi.doMock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner), + })); + + vi.doMock("../../context-engine/index.js", () => ({ + ensureContextEnginesInitialized: vi.fn(), + resolveContextEngine: vi.fn(async () => mockedContextEngine), + })); + + vi.doMock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded, + })); + + vi.doMock("../../plugins/provider-runtime.js", () => ({ + prepareProviderRuntimeAuth: mockedPrepareProviderRuntimeAuth, + })); + + vi.doMock("../auth-profiles.js", () => ({ + isProfileInCooldown: vi.fn(() => false), + markAuthProfileFailure: vi.fn(async () => {}), + markAuthProfileGood: vi.fn(async () => {}), + markAuthProfileUsed: vi.fn(async () => {}), + resolveProfilesUnavailableReason: vi.fn(() => undefined), + })); + + vi.doMock("../usage.js", () => ({ + normalizeUsage: vi.fn((usage?: unknown) => + usage && typeof usage === "object" ? usage : undefined, + ), + derivePromptTokens: vi.fn( + (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => + usage + ? (() => { + const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + return sum > 0 ? sum : undefined; + })() + : undefined, + ), + })); + + vi.doMock("../workspace-run.js", () => ({ + resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({ + workspaceDir: params.workspaceDir, + usedFallback: false, + fallbackReason: undefined, + agentId: "main", + })), + redactRunIdentifier: vi.fn((value?: string) => value ?? ""), + })); + + vi.doMock("../pi-embedded-helpers.js", () => ({ + formatBillingErrorMessage: vi.fn(() => ""), + classifyFailoverReason: mockedClassifyFailoverReason, + extractObservedOverflowTokenCount: mockedExtractObservedOverflowTokenCount, + formatAssistantErrorText: vi.fn(() => ""), + isAuthAssistantError: vi.fn(() => false), + isBillingAssistantError: vi.fn(() => false), + isCompactionFailureError: mockedIsCompactionFailureError, + isLikelyContextOverflowError: mockedIsLikelyContextOverflowError, + isFailoverAssistantError: vi.fn(() => false), + isFailoverErrorMessage: vi.fn(() => false), + parseImageSizeError: vi.fn(() => null), + parseImageDimensionError: vi.fn(() => null), + isRateLimitAssistantError: vi.fn(() => false), + isTimeoutErrorMessage: vi.fn(() => false), + pickFallbackThinkingLevel: mockedPickFallbackThinkingLevel, + })); + + vi.doMock("./run/attempt.js", () => ({ + runEmbeddedAttempt: mockedRunEmbeddedAttempt, + })); + + vi.doMock("./model.js", () => ({ + resolveModelAsync: vi.fn(async () => ({ + model: { + id: "test-model", + provider: "anthropic", + contextWindow: 200000, + api: "messages", + }, + error: null, + authStorage: { + setRuntimeApiKey: vi.fn(), + }, + modelRegistry: {}, + })), + })); + + vi.doMock("../model-auth.js", () => ({ + applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), + ensureAuthProfileStore: vi.fn(() => ({})), + getApiKeyForModel: vi.fn(async () => ({ + apiKey: "test-key", + profileId: "test-profile", + source: "test", + })), + resolveAuthProfileOrder: vi.fn(() => []), + })); + + vi.doMock("../models-config.js", () => ({ + ensureOpenClawModelsJson: vi.fn(async () => {}), + })); + + vi.doMock("../context-window-guard.js", () => ({ + CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000, + CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000, + evaluateContextWindowGuard: vi.fn(() => ({ + shouldWarn: false, + shouldBlock: false, + tokens: 200000, + source: "model", + })), + resolveContextWindowInfo: vi.fn(() => ({ + tokens: 200000, + source: "model", + })), + })); + + vi.doMock("../../process/command-queue.js", () => ({ + enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()), + })); + + vi.doMock("../../utils/message-channel.js", () => ({ + isMarkdownCapableMessageChannel: vi.fn(() => true), + })); + + vi.doMock("../agent-paths.js", () => ({ + resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"), + })); + + vi.doMock("../defaults.js", () => ({ + DEFAULT_CONTEXT_TOKENS: 200000, + DEFAULT_MODEL: "test-model", + DEFAULT_PROVIDER: "anthropic", + })); + + vi.doMock("../failover-error.js", () => ({ + FailoverError: class extends Error {}, + coerceToFailoverError: mockedCoerceToFailoverError, + describeFailoverError: mockedDescribeFailoverError, + resolveFailoverStatus: mockedResolveFailoverStatus, + })); + + vi.doMock("./lanes.js", () => ({ + resolveSessionLane: vi.fn(() => "session-lane"), + resolveGlobalLane: vi.fn(() => "global-lane"), + })); + + vi.doMock("./logger.js", () => ({ + log: mockedLog, + })); + + vi.doMock("./run/payloads.js", () => ({ + buildEmbeddedRunPayloads: vi.fn(() => []), + })); + + vi.doMock("./tool-result-truncation.js", () => ({ + truncateOversizedToolResultsInSession: mockedTruncateOversizedToolResultsInSession, + sessionLikelyHasOversizedToolResults: mockedSessionLikelyHasOversizedToolResults, + })); + + vi.doMock("./utils.js", () => ({ + describeUnknownError: vi.fn((err: unknown) => { + if (err instanceof Error) { + return err.message; + } + return String(err); + }), + })); + + const { runEmbeddedPiAgent } = await import("./run.js"); + return { runEmbeddedPiAgent }; +} diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts index 7a2550ba1e9..f74b14c56df 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts @@ -1,17 +1,4 @@ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { isCompactionFailureError, isLikelyContextOverflowError } from "../pi-embedded-helpers.js"; - -vi.mock(import("../../utils.js"), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveUserPath: vi.fn((p: string) => p), - }; -}); - -import { log } from "./logger.js"; -import { runEmbeddedPiAgent } from "./run.js"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult, makeCompactionSuccess, @@ -20,26 +7,38 @@ import { queueOverflowAttemptWithOversizedToolOutput, } from "./run.overflow-compaction.fixture.js"; import { + loadRunOverflowCompactionHarness, mockedContextEngine, mockedCompactDirect, + mockedIsCompactionFailureError, + mockedIsLikelyContextOverflowError, + mockedLog, mockedRunEmbeddedAttempt, mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, overflowBaseRunParams as baseParams, -} from "./run.overflow-compaction.shared-test.js"; +} from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; -const mockedIsCompactionFailureError = vi.mocked(isCompactionFailureError); -const mockedIsLikelyContextOverflowError = vi.mocked(isLikelyContextOverflowError); +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("overflow compaction in run loop", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - vi.clearAllMocks(); mockedRunEmbeddedAttempt.mockReset(); mockedCompactDirect.mockReset(); mockedSessionLikelyHasOversizedToolResults.mockReset(); mockedTruncateOversizedToolResultsInSession.mockReset(); mockedContextEngine.info.ownsCompaction = false; + mockedLog.debug.mockReset(); + mockedLog.info.mockReset(); + mockedLog.warn.mockReset(); + mockedLog.error.mockReset(); + mockedLog.isEnabled.mockReset(); + mockedLog.isEnabled.mockReturnValue(false); mockedIsCompactionFailureError.mockImplementation((msg?: string) => { if (!msg) { return false; @@ -87,12 +86,14 @@ describe("overflow compaction in run loop", () => { }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.warn).toHaveBeenCalledWith( + expect(mockedLog.warn).toHaveBeenCalledWith( expect.stringContaining( "context overflow detected (attempt 1/3); attempting auto-compaction", ), ); - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("auto-compaction succeeded")); + expect(mockedLog.info).toHaveBeenCalledWith( + expect.stringContaining("auto-compaction succeeded"), + ); // Should not be an error result expect(result.meta.error).toBeUndefined(); }); @@ -116,7 +117,7 @@ describe("overflow compaction in run loop", () => { expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("source=promptError")); + expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("source=promptError")); expect(result.meta.error).toBeUndefined(); }); @@ -137,7 +138,7 @@ describe("overflow compaction in run loop", () => { expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); expect(result.meta.error?.kind).toBe("context_overflow"); expect(result.payloads?.[0]?.isError).toBe(true); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("auto-compaction failed")); + expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("auto-compaction failed")); }); it("falls back to tool-result truncation and retries when oversized results are detected", async () => { @@ -165,7 +166,9 @@ describe("overflow compaction in run loop", () => { expect.objectContaining({ sessionFile: "/tmp/session.json" }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("Truncated 1 tool result(s)")); + expect(mockedLog.info).toHaveBeenCalledWith( + expect.stringContaining("Truncated 1 tool result(s)"), + ); expect(result.meta.error).toBeUndefined(); }); @@ -284,7 +287,7 @@ describe("overflow compaction in run loop", () => { expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("source=assistantError")); + expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("source=assistantError")); expect(result.meta.error).toBeUndefined(); }); @@ -302,7 +305,9 @@ describe("overflow compaction in run loop", () => { await expect(runEmbeddedPiAgent(baseParams)).rejects.toThrow("transport disconnected"); expect(mockedCompactDirect).not.toHaveBeenCalled(); - expect(log.warn).not.toHaveBeenCalledWith(expect.stringContaining("source=assistantError")); + expect(mockedLog.warn).not.toHaveBeenCalledWith( + expect.stringContaining("source=assistantError"), + ); }); it("returns an explicit timeout payload when the run times out before producing any reply", async () => { diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts deleted file mode 100644 index 8451ef54994..00000000000 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { vi } from "vitest"; -import type { - PluginHookAgentContext, - PluginHookBeforeAgentStartResult, - PluginHookBeforeModelResolveResult, - PluginHookBeforePromptBuildResult, -} from "../../plugins/types.js"; - -type MockCompactionResult = - | { - ok: true; - compacted: true; - result: { - summary: string; - firstKeptEntryId?: string; - tokensBefore?: number; - tokensAfter?: number; - }; - reason?: string; - } - | { - ok: false; - compacted: false; - reason: string; - result?: undefined; - }; - -export const mockedGlobalHookRunner = { - hasHooks: vi.fn((_hookName: string) => false), - runBeforeAgentStart: vi.fn( - async ( - _event: { prompt: string; messages?: unknown[] }, - _ctx: PluginHookAgentContext, - ): Promise => undefined, - ), - runBeforePromptBuild: vi.fn( - async ( - _event: { prompt: string; messages: unknown[] }, - _ctx: PluginHookAgentContext, - ): Promise => undefined, - ), - runBeforeModelResolve: vi.fn( - async ( - _event: { prompt: string }, - _ctx: PluginHookAgentContext, - ): Promise => undefined, - ), - runBeforeCompaction: vi.fn(async () => undefined), - runAfterCompaction: vi.fn(async () => undefined), -}; - -export const mockedContextEngine = { - info: { ownsCompaction: false as boolean }, - compact: vi.fn<(params: unknown) => Promise>(async () => ({ - ok: false as const, - compacted: false as const, - reason: "nothing to compact", - })), -}; - -export const mockedContextEngineCompact = vi.mocked(mockedContextEngine.compact); -export const mockedEnsureRuntimePluginsLoaded: (...args: unknown[]) => void = vi.fn(); - -vi.mock("../../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner), -})); - -vi.mock("../../context-engine/index.js", () => ({ - ensureContextEnginesInitialized: vi.fn(), - resolveContextEngine: vi.fn(async () => mockedContextEngine), -})); - -vi.mock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded, -})); - -vi.mock("../auth-profiles.js", () => ({ - isProfileInCooldown: vi.fn(() => false), - markAuthProfileFailure: vi.fn(async () => {}), - markAuthProfileGood: vi.fn(async () => {}), - markAuthProfileUsed: vi.fn(async () => {}), -})); - -vi.mock("../usage.js", () => ({ - normalizeUsage: vi.fn((usage?: unknown) => - usage && typeof usage === "object" ? usage : undefined, - ), - derivePromptTokens: vi.fn((usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => - usage - ? (() => { - const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); - return sum > 0 ? sum : undefined; - })() - : undefined, - ), - hasNonzeroUsage: vi.fn(() => false), -})); - -vi.mock("../workspace-run.js", () => ({ - resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({ - workspaceDir: params.workspaceDir, - usedFallback: false, - fallbackReason: undefined, - agentId: "main", - })), - redactRunIdentifier: vi.fn((value?: string) => value ?? ""), -})); - -vi.mock("../pi-embedded-helpers.js", () => ({ - formatBillingErrorMessage: vi.fn(() => ""), - classifyFailoverReason: vi.fn(() => null), - extractObservedOverflowTokenCount: vi.fn((msg?: string) => { - const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i); - return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined; - }), - formatAssistantErrorText: vi.fn(() => ""), - isAuthAssistantError: vi.fn(() => false), - isBillingAssistantError: vi.fn(() => false), - isCompactionFailureError: vi.fn(() => false), - isLikelyContextOverflowError: vi.fn((msg?: string) => { - const lower = (msg ?? "").toLowerCase(); - return ( - lower.includes("request_too_large") || - lower.includes("context window exceeded") || - lower.includes("prompt is too long") - ); - }), - isFailoverAssistantError: vi.fn(() => false), - isFailoverErrorMessage: vi.fn(() => false), - parseImageSizeError: vi.fn(() => null), - parseImageDimensionError: vi.fn(() => null), - isRateLimitAssistantError: vi.fn(() => false), - isTimeoutErrorMessage: vi.fn(() => false), - pickFallbackThinkingLevel: vi.fn(() => null), -})); - -vi.mock("./run/attempt.js", () => ({ - runEmbeddedAttempt: vi.fn(), -})); - -vi.mock("./compact.js", () => ({ - compactEmbeddedPiSessionDirect: vi.fn(), -})); - -vi.mock("./model.js", () => ({ - resolveModel: vi.fn(() => ({ - model: { - id: "test-model", - provider: "anthropic", - contextWindow: 200000, - api: "messages", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - })), - resolveModelAsync: vi.fn(async () => ({ - model: { - id: "test-model", - provider: "anthropic", - contextWindow: 200000, - api: "messages", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - })), -})); - -vi.mock("../model-auth.js", () => ({ - ensureAuthProfileStore: vi.fn(() => ({})), - getApiKeyForModel: vi.fn(async () => ({ - apiKey: "test-key", - profileId: "test-profile", - source: "test", - })), - resolveAuthProfileOrder: vi.fn(() => []), -})); - -vi.mock("../models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), -})); - -vi.mock("../context-window-guard.js", () => ({ - CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000, - CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000, - evaluateContextWindowGuard: vi.fn(() => ({ - shouldWarn: false, - shouldBlock: false, - tokens: 200000, - source: "model", - })), - resolveContextWindowInfo: vi.fn(() => ({ - tokens: 200000, - source: "model", - })), -})); - -vi.mock("../../process/command-queue.js", () => ({ - enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()), -})); - -vi.mock(import("../../utils/message-channel.js"), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isMarkdownCapableMessageChannel: vi.fn(() => true), - }; -}); - -vi.mock("../agent-paths.js", () => ({ - resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"), -})); - -vi.mock("../defaults.js", () => ({ - DEFAULT_CONTEXT_TOKENS: 200000, - DEFAULT_MODEL: "test-model", - DEFAULT_PROVIDER: "anthropic", -})); - -type MockFailoverErrorDescription = { - message: string; - reason: string | undefined; - status: number | undefined; - code: string | undefined; -}; - -type MockCoerceToFailoverError = ( - err: unknown, - params?: { provider?: string; model?: string; profileId?: string }, -) => unknown; -type MockDescribeFailoverError = (err: unknown) => MockFailoverErrorDescription; -type MockResolveFailoverStatus = (reason: string) => number | undefined; - -export const mockedCoerceToFailoverError = vi.fn(); -export const mockedDescribeFailoverError = vi.fn( - (err: unknown): MockFailoverErrorDescription => ({ - message: err instanceof Error ? err.message : String(err), - reason: undefined, - status: undefined, - code: undefined, - }), -); -export const mockedResolveFailoverStatus = vi.fn(); - -vi.mock("../failover-error.js", () => ({ - FailoverError: class extends Error {}, - coerceToFailoverError: mockedCoerceToFailoverError, - describeFailoverError: mockedDescribeFailoverError, - resolveFailoverStatus: mockedResolveFailoverStatus, -})); - -vi.mock("./lanes.js", () => ({ - resolveSessionLane: vi.fn(() => "session-lane"), - resolveGlobalLane: vi.fn(() => "global-lane"), -})); - -vi.mock("./logger.js", () => ({ - log: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - isEnabled: vi.fn(() => false), - }, -})); - -vi.mock("./run/payloads.js", () => ({ - buildEmbeddedRunPayloads: vi.fn(() => []), -})); - -vi.mock("./tool-result-truncation.js", () => ({ - truncateOversizedToolResultsInSession: vi.fn(async () => ({ - truncated: false, - truncatedCount: 0, - reason: "no oversized tool results", - })), - sessionLikelyHasOversizedToolResults: vi.fn(() => false), -})); - -vi.mock("./utils.js", () => ({ - describeUnknownError: vi.fn((err: unknown) => { - if (err instanceof Error) { - return err.message; - } - return String(err); - }), -})); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts deleted file mode 100644 index c697ac9526a..00000000000 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { vi } from "vitest"; -import { - mockedContextEngine, - mockedContextEngineCompact, -} from "./run.overflow-compaction.mocks.shared.js"; -import { runEmbeddedAttempt } from "./run/attempt.js"; -import { - sessionLikelyHasOversizedToolResults, - truncateOversizedToolResultsInSession, -} from "./tool-result-truncation.js"; - -export const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt); -export const mockedCompactDirect = mockedContextEngineCompact; -export const mockedSessionLikelyHasOversizedToolResults = vi.mocked( - sessionLikelyHasOversizedToolResults, -); -export const mockedTruncateOversizedToolResultsInSession = vi.mocked( - truncateOversizedToolResultsInSession, -); -export { mockedContextEngine }; - -export const overflowBaseRunParams = { - sessionId: "test-session", - sessionKey: "test-key", - sessionFile: "/tmp/session.json", - workspaceDir: "/tmp/workspace", - prompt: "hello", - timeoutMs: 30000, - runId: "run-1", -} as const; diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index d18123a4ae2..75a9ab6e034 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -1,7 +1,4 @@ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { pickFallbackThinkingLevel } from "../pi-embedded-helpers.js"; -import { runEmbeddedPiAgent } from "./run.js"; +import { beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult, makeCompactionSuccess, @@ -10,24 +7,30 @@ import { queueOverflowAttemptWithOversizedToolOutput, } from "./run.overflow-compaction.fixture.js"; import { + loadRunOverflowCompactionHarness, mockedCoerceToFailoverError, mockedDescribeFailoverError, mockedGlobalHookRunner, + mockedPickFallbackThinkingLevel, mockedResolveFailoverStatus, -} from "./run.overflow-compaction.mocks.shared.js"; -import { mockedContextEngine, mockedCompactDirect, mockedRunEmbeddedAttempt, mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, overflowBaseRunParams, -} from "./run.overflow-compaction.shared-test.js"; -const mockedPickFallbackThinkingLevel = vi.mocked(pickFallbackThinkingLevel); +} from "./run.overflow-compaction.harness.js"; + +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { beforeEach(() => { - vi.clearAllMocks(); + return loadRunOverflowCompactionHarness().then((loaded) => { + runEmbeddedPiAgent = loaded.runEmbeddedPiAgent; + }); + }); + + beforeEach(() => { mockedRunEmbeddedAttempt.mockReset(); mockedCompactDirect.mockReset(); mockedCoerceToFailoverError.mockReset(); @@ -257,7 +260,8 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { it("returns retry_limit when repeated retries never converge", async () => { mockedRunEmbeddedAttempt.mockClear(); mockedCompactDirect.mockClear(); - mockedPickFallbackThinkingLevel.mockClear(); + mockedPickFallbackThinkingLevel.mockReset(); + mockedPickFallbackThinkingLevel.mockReturnValue(null); mockedRunEmbeddedAttempt.mockResolvedValue( makeAttemptResult({ promptError: new Error("unsupported reasoning mode") }), ); @@ -288,15 +292,15 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { status: 429, }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError })); - mockedCoerceToFailoverError.mockReturnValueOnce(normalized); + mockedRunEmbeddedAttempt.mockResolvedValue(makeAttemptResult({ promptError })); + mockedCoerceToFailoverError.mockReturnValue(normalized); mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ message: err instanceof Error ? err.message : String(err), reason: err === normalized ? "rate_limit" : undefined, status: err === normalized ? 429 : undefined, code: undefined, })); - mockedResolveFailoverStatus.mockReturnValueOnce(429); + mockedResolveFailoverStatus.mockReturnValue(429); await expect( runEmbeddedPiAgent({ diff --git a/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts b/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts index e05ffd19cbf..69a81d129fb 100644 --- a/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts +++ b/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts @@ -3,20 +3,25 @@ * with no pending tool calls, so the parent session is idle when subagent * results arrive. */ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runEmbeddedPiAgent } from "./run.js"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; -import { mockedGlobalHookRunner } from "./run.overflow-compaction.mocks.shared.js"; import { + loadRunOverflowCompactionHarness, + mockedGlobalHookRunner, mockedRunEmbeddedAttempt, overflowBaseRunParams, -} from "./run.overflow-compaction.shared-test.js"; +} from "./run.overflow-compaction.harness.js"; import { isEmbeddedPiRunActive, queueEmbeddedPiMessage } from "./runs.js"; +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; + describe("sessions_yield orchestration", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - vi.clearAllMocks(); + mockedRunEmbeddedAttempt.mockReset(); mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); }); diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/pi-embedded-runner/usage-reporting.test.ts index 7c29c5f99cf..f748ac3b9b5 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/pi-embedded-runner/usage-reporting.test.ts @@ -1,22 +1,20 @@ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + loadRunOverflowCompactionHarness, + mockedEnsureRuntimePluginsLoaded, + mockedRunEmbeddedAttempt, +} from "./run.overflow-compaction.harness.js"; -const runtimePluginMocks = vi.hoisted(() => ({ - ensureRuntimePluginsLoaded: vi.fn(), -})); - -vi.mock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, -})); - -import { runEmbeddedPiAgent } from "./run.js"; -import { runEmbeddedAttempt } from "./run/attempt.js"; - -const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt); +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("runEmbeddedPiAgent usage reporting", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - vi.clearAllMocks(); + mockedEnsureRuntimePluginsLoaded.mockReset(); + mockedRunEmbeddedAttempt.mockReset(); }); it("bootstraps runtime plugins with the resolved workspace before running", async () => { @@ -39,7 +37,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { runId: "run-plugin-bootstrap", }); - expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + expect(mockedEnsureRuntimePluginsLoaded).toHaveBeenCalledWith({ config: undefined, workspaceDir: "/tmp/workspace", }); @@ -66,7 +64,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { allowGatewaySubagentBinding: true, }); - expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + expect(mockedEnsureRuntimePluginsLoaded).toHaveBeenCalledWith({ config: undefined, workspaceDir: "/tmp/workspace", allowGatewaySubagentBinding: true, From 50cac3965744afb7917fa84801858a2945f2c244 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 01:06:48 +0000 Subject: [PATCH 07/31] Agents: stabilize compaction hook test harness --- .../compact.hooks.harness.ts | 410 ++++++++++++++++++ .../pi-embedded-runner/compact.hooks.test.ts | 371 ++-------------- 2 files changed, 445 insertions(+), 336 deletions(-) create mode 100644 src/agents/pi-embedded-runner/compact.hooks.harness.ts diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts new file mode 100644 index 00000000000..fa796a1a59d --- /dev/null +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -0,0 +1,410 @@ +import { vi, type Mock } from "vitest"; + +type MockResolvedModel = { + model: { provider: string; api: string; id: string; input: unknown[] }; + error: null; + authStorage: { setRuntimeApiKey: Mock<(provider?: string, apiKey?: string) => void> }; + modelRegistry: Record; +}; + +export const contextEngineCompactMock = vi.fn(async () => ({ + ok: true as boolean, + compacted: true as boolean, + reason: undefined as string | undefined, + result: { summary: "engine-summary", tokensAfter: 50 } as + | { summary: string; tokensAfter: number } + | undefined, +})); + +export const hookRunner = { + hasHooks: vi.fn<(hookName?: string) => boolean>(), + runBeforeCompaction: vi.fn(async () => undefined), + runAfterCompaction: vi.fn(async () => undefined), +}; + +export const ensureRuntimePluginsLoaded: Mock<(params?: unknown) => void> = vi.fn(); +export const resolveContextEngineMock = vi.fn(async () => ({ + info: { ownsCompaction: true as boolean }, + compact: contextEngineCompactMock, +})); +export const resolveModelMock: Mock< + (provider?: string, modelId?: string, agentDir?: string, cfg?: unknown) => MockResolvedModel +> = vi.fn((_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({ + model: { provider: "openai", api: "responses", id: "fake", input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, +})); +export const sessionCompactImpl = vi.fn(async () => ({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + details: { ok: true }, +})); +export const triggerInternalHook: Mock<(event?: unknown) => void> = vi.fn(); +export const sanitizeSessionHistoryMock = vi.fn( + async (params: { messages: unknown[] }) => params.messages, +); +export const getMemorySearchManagerMock = vi.fn(async () => ({ + manager: { + sync: vi.fn(async () => {}), + }, +})); +export const resolveMemorySearchConfigMock = vi.fn(() => ({ + sources: ["sessions"], + sync: { + sessions: { + postCompactionForce: true, + }, + }, +})); +export const resolveSessionAgentIdMock = vi.fn(() => "main"); +export const estimateTokensMock = vi.fn((_message?: unknown) => 10); +export const sessionAbortCompactionMock: Mock<(reason?: unknown) => void> = vi.fn(); +export const createOpenClawCodingToolsMock = vi.fn(() => []); + +export function resetCompactHooksHarnessMocks(): void { + hookRunner.hasHooks.mockReset(); + hookRunner.hasHooks.mockReturnValue(false); + hookRunner.runBeforeCompaction.mockReset(); + hookRunner.runBeforeCompaction.mockResolvedValue(undefined); + hookRunner.runAfterCompaction.mockReset(); + hookRunner.runAfterCompaction.mockResolvedValue(undefined); + + ensureRuntimePluginsLoaded.mockReset(); + + resolveContextEngineMock.mockReset(); + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: true }, + compact: contextEngineCompactMock, + }); + contextEngineCompactMock.mockReset(); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + reason: undefined, + result: { summary: "engine-summary", tokensAfter: 50 }, + }); + + resolveModelMock.mockReset(); + resolveModelMock.mockReturnValue({ + model: { provider: "openai", api: "responses", id: "fake", input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }); + + sessionCompactImpl.mockReset(); + sessionCompactImpl.mockResolvedValue({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + details: { ok: true }, + }); + + triggerInternalHook.mockReset(); + sanitizeSessionHistoryMock.mockReset(); + sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => { + return params.messages; + }); + + getMemorySearchManagerMock.mockReset(); + getMemorySearchManagerMock.mockResolvedValue({ + manager: { + sync: vi.fn(async () => {}), + }, + }); + resolveMemorySearchConfigMock.mockReset(); + resolveMemorySearchConfigMock.mockReturnValue({ + sources: ["sessions"], + sync: { + sessions: { + postCompactionForce: true, + }, + }, + }); + resolveSessionAgentIdMock.mockReset(); + resolveSessionAgentIdMock.mockReturnValue("main"); + estimateTokensMock.mockReset(); + estimateTokensMock.mockReturnValue(10); + sessionAbortCompactionMock.mockReset(); + createOpenClawCodingToolsMock.mockReset(); + createOpenClawCodingToolsMock.mockReturnValue([]); +} + +export async function loadCompactHooksHarness(): Promise<{ + compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect; + compactEmbeddedPiSession: typeof import("./compact.js").compactEmbeddedPiSession; + onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; +}> { + resetCompactHooksHarnessMocks(); + vi.resetModules(); + + vi.doMock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => hookRunner, + })); + + vi.doMock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded, + })); + + vi.doMock("../../hooks/internal-hooks.js", async () => { + const actual = await vi.importActual( + "../../hooks/internal-hooks.js", + ); + return { + ...actual, + triggerInternalHook, + }; + }); + + vi.doMock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: vi.fn(), + getOAuthProviders: vi.fn(() => []), + })); + + vi.doMock("@mariozechner/pi-coding-agent", () => ({ + AuthStorage: class AuthStorage {}, + ModelRegistry: class ModelRegistry {}, + createAgentSession: vi.fn(async () => { + const session = { + sessionId: "session-1", + messages: [ + { role: "user", content: "hello", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "exec", + content: [{ type: "text", text: "output" }], + isError: false, + timestamp: 3, + }, + ], + agent: { + replaceMessages: vi.fn((messages: unknown[]) => { + session.messages = [...(messages as typeof session.messages)]; + }), + streamFn: vi.fn(), + }, + compact: vi.fn(async () => { + session.messages.splice(1); + return await sessionCompactImpl(); + }), + abortCompaction: sessionAbortCompactionMock, + dispose: vi.fn(), + }; + return { session }; + }), + DefaultResourceLoader: class DefaultResourceLoader {}, + SessionManager: { + open: vi.fn(() => ({})), + }, + SettingsManager: { + create: vi.fn(() => ({})), + }, + estimateTokens: estimateTokensMock, + })); + + vi.doMock("../session-tool-result-guard-wrapper.js", () => ({ + guardSessionManager: vi.fn(() => ({ + flushPendingToolResults: vi.fn(), + })), + })); + + vi.doMock("../pi-settings.js", () => ({ + ensurePiCompactionReserveTokens: vi.fn(), + resolveCompactionReserveTokensFloor: vi.fn(() => 0), + })); + + vi.doMock("../models-config.js", () => ({ + ensureOpenClawModelsJson: vi.fn(async () => {}), + })); + + vi.doMock("../model-auth.js", () => ({ + applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), + getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })), + resolveModelAuthMode: vi.fn(() => "env"), + })); + + vi.doMock("../sandbox.js", () => ({ + resolveSandboxContext: vi.fn(async () => null), + })); + + vi.doMock("../session-file-repair.js", () => ({ + repairSessionFileIfNeeded: vi.fn(async () => {}), + })); + + vi.doMock("../session-write-lock.js", () => ({ + acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), + resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0), + })); + + vi.doMock("../../context-engine/index.js", () => ({ + ensureContextEnginesInitialized: vi.fn(), + resolveContextEngine: resolveContextEngineMock, + })); + + vi.doMock("../../process/command-queue.js", () => ({ + enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()), + })); + + vi.doMock("./lanes.js", () => ({ + resolveSessionLane: vi.fn(() => "test-session-lane"), + resolveGlobalLane: vi.fn(() => "test-global-lane"), + })); + + vi.doMock("../context-window-guard.js", () => ({ + resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })), + })); + + vi.doMock("../bootstrap-files.js", () => ({ + makeBootstrapWarn: vi.fn(() => () => {}), + resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })), + })); + + vi.doMock("../docs-path.js", () => ({ + resolveOpenClawDocsPath: vi.fn(async () => undefined), + })); + + vi.doMock("../channel-tools.js", () => ({ + listChannelSupportedActions: vi.fn(() => undefined), + resolveChannelMessageToolHints: vi.fn(() => undefined), + })); + + vi.doMock("../pi-tools.js", () => ({ + createOpenClawCodingTools: createOpenClawCodingToolsMock, + })); + + vi.doMock("./google.js", () => ({ + logToolSchemasForGoogle: vi.fn(), + sanitizeSessionHistory: sanitizeSessionHistoryMock, + sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools), + })); + + vi.doMock("./tool-split.js", () => ({ + splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })), + })); + + vi.doMock("../transcript-policy.js", () => ({ + resolveTranscriptPolicy: vi.fn(() => ({ + allowSyntheticToolResults: false, + validateGeminiTurns: false, + validateAnthropicTurns: false, + })), + })); + + vi.doMock("./extensions.js", () => ({ + buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })), + })); + + vi.doMock("./history.js", () => ({ + getDmHistoryLimitFromSessionKey: vi.fn(() => undefined), + limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs.slice(0, 2)), + })); + + vi.doMock("../skills.js", () => ({ + applySkillEnvOverrides: vi.fn(() => () => {}), + applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}), + loadWorkspaceSkillEntries: vi.fn(() => []), + resolveSkillsPromptForRun: vi.fn(() => undefined), + })); + + vi.doMock("../agent-paths.js", () => ({ + resolveOpenClawAgentDir: vi.fn(() => "/tmp"), + })); + + vi.doMock("../agent-scope.js", () => ({ + resolveSessionAgentId: resolveSessionAgentIdMock, + resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), + })); + + vi.doMock("../memory-search.js", () => ({ + resolveMemorySearchConfig: resolveMemorySearchConfigMock, + })); + + vi.doMock("../../memory/index.js", () => ({ + getMemorySearchManager: getMemorySearchManagerMock, + })); + + vi.doMock("../date-time.js", () => ({ + formatUserTime: vi.fn(() => ""), + resolveUserTimeFormat: vi.fn(() => ""), + resolveUserTimezone: vi.fn(() => ""), + })); + + vi.doMock("../defaults.js", () => ({ + DEFAULT_MODEL: "fake-model", + DEFAULT_PROVIDER: "openai", + DEFAULT_CONTEXT_TOKENS: 128_000, + })); + + vi.doMock("../utils.js", () => ({ + resolveUserPath: vi.fn((p: string) => p), + })); + + vi.doMock("../../infra/machine-name.js", () => ({ + getMachineDisplayName: vi.fn(async () => "machine"), + })); + + vi.doMock("../../config/channel-capabilities.js", () => ({ + resolveChannelCapabilities: vi.fn(() => undefined), + })); + + vi.doMock("../../utils/message-channel.js", () => ({ + INTERNAL_MESSAGE_CHANNEL: "webchat", + normalizeMessageChannel: vi.fn(() => undefined), + })); + + vi.doMock("../pi-embedded-helpers.js", () => ({ + ensureSessionHeader: vi.fn(async () => {}), + validateAnthropicTurns: vi.fn((m: unknown[]) => m), + validateGeminiTurns: vi.fn((m: unknown[]) => m), + })); + + vi.doMock("../pi-project-settings.js", () => ({ + createPreparedEmbeddedPiSettingsManager: vi.fn(() => ({ + getGlobalSettings: vi.fn(() => ({})), + })), + })); + + vi.doMock("./sandbox-info.js", () => ({ + buildEmbeddedSandboxInfo: vi.fn(() => undefined), + })); + + vi.doMock("./model.js", () => ({ + buildModelAliasLines: vi.fn(() => []), + resolveModel: resolveModelMock, + resolveModelAsync: vi.fn( + async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => + resolveModelMock(provider, modelId, agentDir, cfg), + ), + })); + + vi.doMock("./session-manager-cache.js", () => ({ + prewarmSessionFile: vi.fn(async () => {}), + trackSessionManagerAccess: vi.fn(), + })); + + vi.doMock("./system-prompt.js", () => ({ + applySystemPromptOverrideToSession: vi.fn(), + buildEmbeddedSystemPrompt: vi.fn(() => ""), + createSystemPromptOverride: vi.fn(() => () => ""), + })); + + vi.doMock("./utils.js", () => ({ + describeUnknownError: vi.fn((err: unknown) => String(err)), + mapThinkingLevel: vi.fn(() => "off"), + resolveExecToolDefaults: vi.fn(() => undefined), + })); + + const [compactModule, transcriptEvents] = await Promise.all([ + import("./compact.js"), + import("../../sessions/transcript-events.js"), + ]); + + return { + ...compactModule, + onSessionTranscriptUpdate: transcriptEvents.onSessionTranscriptUpdate, + }; +} diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 72b16ad003f..8989176330a 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -1,348 +1,39 @@ +import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; - -const { - hookRunner, +import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; +import { + contextEngineCompactMock, + createOpenClawCodingToolsMock, ensureRuntimePluginsLoaded, + estimateTokensMock, + getMemorySearchManagerMock, + hookRunner, + loadCompactHooksHarness, resolveContextEngineMock, + resolveMemorySearchConfigMock, resolveModelMock, + resolveSessionAgentIdMock, + sanitizeSessionHistoryMock, + sessionAbortCompactionMock, sessionCompactImpl, triggerInternalHook, - sanitizeSessionHistoryMock, - contextEngineCompactMock, - getMemorySearchManagerMock, - resolveMemorySearchConfigMock, - resolveSessionAgentIdMock, - estimateTokensMock, - sessionAbortCompactionMock, - createOpenClawCodingToolsMock, -} = vi.hoisted(() => { - const contextEngineCompactMock = vi.fn(async () => ({ - ok: true as boolean, - compacted: true as boolean, - reason: undefined as string | undefined, - result: { summary: "engine-summary", tokensAfter: 50 } as - | { summary: string; tokensAfter: number } - | undefined, - })); +} from "./compact.hooks.harness.js"; - return { - hookRunner: { - hasHooks: vi.fn(), - runBeforeCompaction: vi.fn(), - runAfterCompaction: vi.fn(), - }, - ensureRuntimePluginsLoaded: vi.fn(), - resolveContextEngineMock: vi.fn(async () => ({ - info: { ownsCompaction: true }, - compact: contextEngineCompactMock, - })), - resolveModelMock: vi.fn( - (_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({ - model: { provider: "openai", api: "responses", id: "fake", input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - }), - ), - sessionCompactImpl: vi.fn(async () => ({ - summary: "summary", - firstKeptEntryId: "entry-1", - tokensBefore: 120, - details: { ok: true }, - })), - triggerInternalHook: vi.fn(), - sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages), - contextEngineCompactMock, - getMemorySearchManagerMock: vi.fn(async () => ({ - manager: { - sync: vi.fn(async () => {}), - }, - })), - resolveMemorySearchConfigMock: vi.fn(() => ({ - sources: ["sessions"], - sync: { - sessions: { - postCompactionForce: true, - }, - }, - })), - resolveSessionAgentIdMock: vi.fn(() => "main"), - estimateTokensMock: vi.fn((_message?: unknown) => 10), - sessionAbortCompactionMock: vi.fn(), - createOpenClawCodingToolsMock: vi.fn(() => []), - }; -}); - -vi.mock("../../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: () => hookRunner, -})); - -vi.mock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded, -})); - -vi.mock("../../hooks/internal-hooks.js", async () => { - const actual = await vi.importActual( - "../../hooks/internal-hooks.js", - ); - return { - ...actual, - triggerInternalHook, - }; -}); - -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: vi.fn(), - getOAuthProviders: vi.fn(() => []), -})); - -vi.mock("@mariozechner/pi-coding-agent", () => { - return { - AuthStorage: class AuthStorage {}, - ModelRegistry: class ModelRegistry {}, - createAgentSession: vi.fn(async () => { - const session = { - sessionId: "session-1", - messages: [ - { role: "user", content: "hello", timestamp: 1 }, - { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, - { - role: "toolResult", - toolCallId: "t1", - toolName: "exec", - content: [{ type: "text", text: "output" }], - isError: false, - timestamp: 3, - }, - ], - agent: { - replaceMessages: vi.fn((messages: unknown[]) => { - session.messages = [...(messages as typeof session.messages)]; - }), - streamFn: vi.fn(), - }, - compact: vi.fn(async () => { - // simulate compaction trimming to a single message - session.messages.splice(1); - return await sessionCompactImpl(); - }), - abortCompaction: sessionAbortCompactionMock, - dispose: vi.fn(), - }; - return { session }; - }), - SessionManager: { - open: vi.fn(() => ({})), - }, - SettingsManager: { - create: vi.fn(() => ({})), - }, - estimateTokens: estimateTokensMock, - }; -}); - -vi.mock("../session-tool-result-guard-wrapper.js", () => ({ - guardSessionManager: vi.fn(() => ({ - flushPendingToolResults: vi.fn(), - })), -})); - -vi.mock("../pi-settings.js", () => ({ - ensurePiCompactionReserveTokens: vi.fn(), - resolveCompactionReserveTokensFloor: vi.fn(() => 0), -})); - -vi.mock("../models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), -})); - -vi.mock("../model-auth.js", () => ({ - applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), - getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })), - resolveModelAuthMode: vi.fn(() => "env"), -})); - -vi.mock("../sandbox.js", () => ({ - resolveSandboxContext: vi.fn(async () => null), -})); - -vi.mock("../session-file-repair.js", () => ({ - repairSessionFileIfNeeded: vi.fn(async () => {}), -})); - -vi.mock("../session-write-lock.js", () => ({ - acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), - resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0), -})); - -vi.mock("../../context-engine/index.js", () => ({ - ensureContextEnginesInitialized: vi.fn(), - resolveContextEngine: resolveContextEngineMock, -})); - -vi.mock("../../process/command-queue.js", () => ({ - enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()), -})); - -vi.mock("./lanes.js", () => ({ - resolveSessionLane: vi.fn(() => "test-session-lane"), - resolveGlobalLane: vi.fn(() => "test-global-lane"), -})); - -vi.mock("../context-window-guard.js", () => ({ - resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })), -})); - -vi.mock("../bootstrap-files.js", () => ({ - makeBootstrapWarn: vi.fn(() => () => {}), - resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })), -})); - -vi.mock("../docs-path.js", () => ({ - resolveOpenClawDocsPath: vi.fn(async () => undefined), -})); - -vi.mock("../channel-tools.js", () => ({ - listChannelSupportedActions: vi.fn(() => undefined), - resolveChannelMessageToolHints: vi.fn(() => undefined), -})); - -vi.mock("../pi-tools.js", () => ({ - createOpenClawCodingTools: createOpenClawCodingToolsMock, -})); - -vi.mock("./google.js", () => ({ - logToolSchemasForGoogle: vi.fn(), - sanitizeSessionHistory: sanitizeSessionHistoryMock, - sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools), -})); - -vi.mock("./tool-split.js", () => ({ - splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })), -})); - -vi.mock("../transcript-policy.js", () => ({ - resolveTranscriptPolicy: vi.fn(() => ({ - allowSyntheticToolResults: false, - validateGeminiTurns: false, - validateAnthropicTurns: false, - })), -})); - -vi.mock("./extensions.js", () => ({ - buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })), -})); - -vi.mock("./history.js", () => ({ - getDmHistoryLimitFromSessionKey: vi.fn(() => undefined), - limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs.slice(0, 2)), -})); - -vi.mock("../skills.js", () => ({ - applySkillEnvOverrides: vi.fn(() => () => {}), - applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}), - loadWorkspaceSkillEntries: vi.fn(() => []), - resolveSkillsPromptForRun: vi.fn(() => undefined), -})); - -vi.mock("../agent-paths.js", () => ({ - resolveOpenClawAgentDir: vi.fn(() => "/tmp"), -})); - -vi.mock("../agent-scope.js", () => ({ - resolveSessionAgentId: resolveSessionAgentIdMock, - resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), -})); - -vi.mock("../memory-search.js", () => ({ - resolveMemorySearchConfig: resolveMemorySearchConfigMock, -})); - -vi.mock("../../memory/index.js", () => ({ - getMemorySearchManager: getMemorySearchManagerMock, -})); - -vi.mock("../date-time.js", () => ({ - formatUserTime: vi.fn(() => ""), - resolveUserTimeFormat: vi.fn(() => ""), - resolveUserTimezone: vi.fn(() => ""), -})); - -vi.mock("../defaults.js", () => ({ - DEFAULT_MODEL: "fake-model", - DEFAULT_PROVIDER: "openai", - DEFAULT_CONTEXT_TOKENS: 128_000, -})); - -vi.mock("../utils.js", () => ({ - resolveUserPath: vi.fn((p: string) => p), -})); - -vi.mock("../../infra/machine-name.js", () => ({ - getMachineDisplayName: vi.fn(async () => "machine"), -})); - -vi.mock("../../config/channel-capabilities.js", () => ({ - resolveChannelCapabilities: vi.fn(() => undefined), -})); - -vi.mock("../../utils/message-channel.js", () => ({ - INTERNAL_MESSAGE_CHANNEL: "webchat", - normalizeMessageChannel: vi.fn(() => undefined), -})); - -vi.mock("../pi-embedded-helpers.js", () => ({ - ensureSessionHeader: vi.fn(async () => {}), - validateAnthropicTurns: vi.fn((m: unknown[]) => m), - validateGeminiTurns: vi.fn((m: unknown[]) => m), -})); - -vi.mock("../pi-project-settings.js", () => ({ - createPreparedEmbeddedPiSettingsManager: vi.fn(() => ({ - getGlobalSettings: vi.fn(() => ({})), - })), -})); - -vi.mock("./sandbox-info.js", () => ({ - buildEmbeddedSandboxInfo: vi.fn(() => undefined), -})); - -vi.mock("./model.js", () => ({ - buildModelAliasLines: vi.fn(() => []), - resolveModel: resolveModelMock, - resolveModelAsync: vi.fn( - async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => - resolveModelMock(provider, modelId, agentDir, cfg), - ), -})); - -vi.mock("./session-manager-cache.js", () => ({ - prewarmSessionFile: vi.fn(async () => {}), - trackSessionManagerAccess: vi.fn(), -})); - -vi.mock("./system-prompt.js", () => ({ - applySystemPromptOverrideToSession: vi.fn(), - buildEmbeddedSystemPrompt: vi.fn(() => ""), - createSystemPromptOverride: vi.fn(() => () => ""), -})); - -vi.mock("./utils.js", () => ({ - describeUnknownError: vi.fn((err: unknown) => String(err)), - mapThinkingLevel: vi.fn(() => "off"), - resolveExecToolDefaults: vi.fn(() => undefined), -})); - -import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; -import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; -import { compactEmbeddedPiSessionDirect, compactEmbeddedPiSession } from "./compact.js"; +let compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect; +let compactEmbeddedPiSession: typeof import("./compact.js").compactEmbeddedPiSession; +let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; const TEST_SESSION_ID = "session-1"; const TEST_SESSION_KEY = "agent:main:session-1"; const TEST_SESSION_FILE = "/tmp/session.jsonl"; const TEST_WORKSPACE_DIR = "/tmp"; const TEST_CUSTOM_INSTRUCTIONS = "focus on decisions"; +type SessionHookEvent = { + type?: string; + action?: string; + sessionKey?: string; + context?: Record; +}; function mockResolvedModel() { resolveModelMock.mockReset(); @@ -389,10 +80,18 @@ function wrappedCompactionArgs(overrides: Record = {}) { }; } -const sessionHook = (action: string) => - triggerInternalHook.mock.calls.find( - (call) => call[0]?.type === "session" && call[0]?.action === action, - )?.[0]; +const sessionHook = (action: string): SessionHookEvent | undefined => + triggerInternalHook.mock.calls.find((call) => { + const event = call[0] as SessionHookEvent | undefined; + return event?.type === "session" && event.action === action; + })?.[0] as SessionHookEvent | undefined; + +beforeEach(async () => { + const loaded = await loadCompactHooksHarness(); + compactEmbeddedPiSessionDirect = loaded.compactEmbeddedPiSessionDirect; + compactEmbeddedPiSession = loaded.compactEmbeddedPiSession; + onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate; +}); describe("compactEmbeddedPiSessionDirect hooks", () => { beforeEach(() => { From 9a455a8c08fcd4b63a70c056805216fc7f157c27 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 01:15:51 +0000 Subject: [PATCH 08/31] Tests: remove compaction hook polling --- .../compact.hooks.harness.ts | 11 ++- .../pi-embedded-runner/compact.hooks.test.ts | 74 +++++++++++-------- 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index fa796a1a59d..e065b0105b3 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -6,6 +6,11 @@ type MockResolvedModel = { authStorage: { setRuntimeApiKey: Mock<(provider?: string, apiKey?: string) => void> }; modelRegistry: Record; }; +type MockMemorySearchManager = { + manager: { + sync: (params?: unknown) => Promise; + }; +}; export const contextEngineCompactMock = vi.fn(async () => ({ ok: true as boolean, @@ -45,9 +50,11 @@ export const triggerInternalHook: Mock<(event?: unknown) => void> = vi.fn(); export const sanitizeSessionHistoryMock = vi.fn( async (params: { messages: unknown[] }) => params.messages, ); -export const getMemorySearchManagerMock = vi.fn(async () => ({ +export const getMemorySearchManagerMock: Mock< + (params?: unknown) => Promise +> = vi.fn(async () => ({ manager: { - sync: vi.fn(async () => {}), + sync: vi.fn(async (_params?: unknown) => {}), }, })); export const resolveMemorySearchConfigMock = vi.fn(() => ({ diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 8989176330a..c5806609c0d 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -34,6 +34,23 @@ type SessionHookEvent = { sessionKey?: string; context?: Record; }; +type PostCompactionSyncParams = { + reason: string; + sessionFiles: string[]; +}; +type PostCompactionSync = (params?: unknown) => Promise; +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} function mockResolvedModel() { resolveModelMock.mockReset(); @@ -388,11 +405,12 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("awaits post-compaction memory sync in await mode when postCompactionForce is true", async () => { - let releaseSync: (() => void) | undefined; - const syncGate = new Promise((resolve) => { - releaseSync = resolve; + const syncStarted = createDeferred(); + const syncRelease = createDeferred(); + const sync = vi.fn(async (params) => { + syncStarted.resolve(params as PostCompactionSyncParams); + await syncRelease.promise; }); - const sync = vi.fn(() => syncGate); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); let settled = false; @@ -405,14 +423,12 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { void resultPromise.then(() => { settled = true; }); - await vi.waitFor(() => { - expect(sync).toHaveBeenCalledWith({ - reason: "post-compaction", - sessionFiles: [TEST_SESSION_FILE], - }); + await expect(syncStarted.promise).resolves.toEqual({ + reason: "post-compaction", + sessionFiles: [TEST_SESSION_FILE], }); expect(settled).toBe(false); - releaseSync?.(); + syncRelease.resolve(undefined); const result = await resultPromise; expect(result.ok).toBe(true); expect(settled).toBe(true); @@ -435,12 +451,17 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("fires post-compaction memory sync without awaiting it in async mode", async () => { - const sync = vi.fn(async () => {}); - let resolveManager: ((value: { manager: { sync: typeof sync } }) => void) | undefined; - const managerGate = new Promise<{ manager: { sync: typeof sync } }>((resolve) => { - resolveManager = resolve; + const sync = vi.fn(async () => {}); + const managerRequested = createDeferred(); + const managerGate = createDeferred<{ manager: { sync: PostCompactionSync } }>(); + const syncStarted = createDeferred(); + sync.mockImplementation(async (params) => { + syncStarted.resolve(params as PostCompactionSyncParams); + }); + getMemorySearchManagerMock.mockImplementation(async () => { + managerRequested.resolve(undefined); + return await managerGate.promise; }); - getMemorySearchManagerMock.mockImplementation(() => managerGate); let settled = false; const resultPromise = compactEmbeddedPiSessionDirect( @@ -449,26 +470,19 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }), ); - await vi.waitFor(() => { - expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1); - }); + await managerRequested.promise; void resultPromise.then(() => { settled = true; }); - await vi.waitFor(() => { - expect(settled).toBe(true); - }); + await resultPromise; + expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1); + expect(settled).toBe(true); expect(sync).not.toHaveBeenCalled(); - resolveManager?.({ manager: { sync } }); - await managerGate; - await vi.waitFor(() => { - expect(sync).toHaveBeenCalledWith({ - reason: "post-compaction", - sessionFiles: [TEST_SESSION_FILE], - }); + managerGate.resolve({ manager: { sync } }); + await expect(syncStarted.promise).resolves.toEqual({ + reason: "post-compaction", + sessionFiles: [TEST_SESSION_FILE], }); - const result = await resultPromise; - expect(result.ok).toBe(true); }); it("registers the Ollama api provider before compaction", async () => { From 2d3bcbfe081880da23c3f44d2b84f062daf341bb Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:20:11 -0500 Subject: [PATCH 09/31] CLI: skip exec SecretRef dry-run resolution unless explicitly allowed (#49322) * CLI: gate exec SecretRef dry-run resolution behind opt-in * Docs: clarify config dry-run exec opt-in behavior * CLI: preserve static exec dry-run validation --- docs/cli/config.md | 31 +++- docs/cli/index.md | 5 +- src/cli/config-cli.integration.test.ts | 144 +++++++++++++++++ src/cli/config-cli.test.ts | 213 ++++++++++++++++++++++++- src/cli/config-cli.ts | 113 ++++++++++++- src/cli/config-set-dryrun.ts | 2 + src/cli/config-set-input.ts | 1 + 7 files changed, 495 insertions(+), 14 deletions(-) diff --git a/docs/cli/config.md b/docs/cli/config.md index ba4e6adf60f..72ba3af0c9d 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -176,19 +176,31 @@ openclaw config set channels.discord.token \ --ref-id DISCORD_BOT_TOKEN \ --dry-run \ --json + +openclaw config set channels.discord.token \ + --ref-provider vault \ + --ref-source exec \ + --ref-id discord/token \ + --dry-run \ + --allow-exec ``` Dry-run behavior: -- Builder mode: requires full SecretRef resolvability for changed refs/providers. -- JSON mode (`--strict-json`, `--json`, or batch mode): requires full resolvability and schema validation. +- Builder mode: runs SecretRef resolvability checks for changed refs/providers. +- JSON mode (`--strict-json`, `--json`, or batch mode): runs schema validation plus SecretRef resolvability checks. +- Exec SecretRef checks are skipped by default during dry-run to avoid command side effects. +- Use `--allow-exec` with `--dry-run` to opt in to exec SecretRef checks (this may execute provider commands). +- `--allow-exec` is dry-run only and errors if used without `--dry-run`. `--dry-run --json` prints a machine-readable report: - `ok`: whether dry-run passed - `operations`: number of assignments evaluated - `checks`: whether schema/resolvability checks ran -- `refsChecked`: number of refs resolved during dry-run +- `checks.resolvabilityComplete`: whether resolvability checks ran to completion (false when exec refs are skipped) +- `refsChecked`: number of refs actually resolved during dry-run +- `skippedExecRefs`: number of exec refs skipped because `--allow-exec` was not set - `errors`: structured schema/resolvability failures when `ok=false` ### JSON Output Shape @@ -202,8 +214,10 @@ Dry-run behavior: checks: { schema: boolean, resolvability: boolean, + resolvabilityComplete: boolean, }, refsChecked: number, + skippedExecRefs: number, errors?: [ { kind: "schema" | "resolvability", @@ -224,9 +238,11 @@ Success example: "inputModes": ["builder"], "checks": { "schema": false, - "resolvability": true + "resolvability": true, + "resolvabilityComplete": true }, - "refsChecked": 1 + "refsChecked": 1, + "skippedExecRefs": 0 } ``` @@ -240,9 +256,11 @@ Failure example: "inputModes": ["builder"], "checks": { "schema": false, - "resolvability": true + "resolvability": true, + "resolvabilityComplete": true }, "refsChecked": 1, + "skippedExecRefs": 0, "errors": [ { "kind": "resolvability", @@ -257,6 +275,7 @@ If dry-run fails: - `config schema validation failed`: your post-change config shape is invalid; fix path/value or provider/ref object shape. - `SecretRef assignment(s) could not be resolved`: referenced provider/ref currently cannot resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch). +- `Dry run note: skipped exec SecretRef resolvability check(s)`: dry-run skipped exec refs; rerun with `--allow-exec` if you need exec resolvability validation. - For batch mode, fix failing entries and rerun `--dry-run` before writing. ## Subcommands diff --git a/docs/cli/index.md b/docs/cli/index.md index 5acbb4b3166..a247a4085de 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -400,8 +400,9 @@ Subcommands: - SecretRef builder mode: `config set --ref-provider --ref-source --ref-id ` - provider builder mode: `config set secrets.providers. --provider-source ...` - batch mode: `config set --batch-json ''` or `config set --batch-file ` -- `config set --dry-run`: validate assignments without writing `openclaw.json`. -- `config set --dry-run --json`: emit machine-readable dry-run output (checks, operations, errors). +- `config set --dry-run`: validate assignments without writing `openclaw.json` (exec SecretRef checks are skipped by default). +- `config set --allow-exec --dry-run`: opt in to exec SecretRef dry-run checks (may execute provider commands). +- `config set --dry-run --json`: emit machine-readable dry-run output (checks + completeness signal, operations, refs checked/skipped, errors). - `config set --strict-json`: require JSON5 parsing for path/value input. `--json` remains a legacy alias for strict parsing outside dry-run output mode. - `config unset `: remove a value. - `config file`: print the active config file path. diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts index 1224d56c220..ed749019c34 100644 --- a/src/cli/config-cli.integration.test.ts +++ b/src/cli/config-cli.integration.test.ts @@ -23,6 +23,39 @@ function createTestRuntime() { }; } +function createExecDryRunBatch(params: { markerPath: string }) { + const response = JSON.stringify({ + protocolVersion: 1, + values: { + dryrun_id: "ok", + }, + }); + const script = [ + 'const fs = require("node:fs");', + `fs.writeFileSync(${JSON.stringify(params.markerPath)}, "dryrun\\n", "utf8");`, + `process.stdout.write(${JSON.stringify(response)});`, + ].join(""); + return [ + { + path: "secrets.providers.runner", + provider: { + source: "exec", + command: process.execPath, + args: ["-e", script], + allowInsecurePath: true, + }, + }, + { + path: "channels.discord.token", + ref: { + source: "exec", + provider: "runner", + id: "dryrun_id", + }, + }, + ]; +} + describe("config cli integration", () => { it("supports batch-file dry-run and then writes real config changes", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-")); @@ -183,4 +216,115 @@ describe("config cli integration", () => { fs.rmSync(tempDir, { recursive: true, force: true }); } }); + + it("skips exec provider execution during dry-run by default", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-exec-skip-")); + const configPath = path.join(tempDir, "openclaw.json"); + const batchPath = path.join(tempDir, "batch.json"); + const markerPath = path.join(tempDir, "marker.txt"); + const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + }, + null, + 2, + )}\n`, + "utf8", + ); + fs.writeFileSync( + batchPath, + `${JSON.stringify(createExecDryRunBatch({ markerPath }), null, 2)}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + const before = fs.readFileSync(configPath, "utf8"); + await runConfigSet({ + cliOptions: { + batchFile: batchPath, + dryRun: true, + }, + runtime: runtime.runtime, + }); + const after = fs.readFileSync(configPath, "utf8"); + + expect(after).toBe(before); + expect(fs.existsSync(markerPath)).toBe(false); + expect( + runtime.logs.some((line) => + line.includes("Dry run note: skipped 1 exec SecretRef resolvability check(s)."), + ), + ).toBe(true); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("executes exec providers during dry-run when --allow-exec is set", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-exec-allow-")); + const configPath = path.join(tempDir, "openclaw.json"); + const batchPath = path.join(tempDir, "batch.json"); + const markerPath = path.join(tempDir, "marker.txt"); + const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + }, + null, + 2, + )}\n`, + "utf8", + ); + fs.writeFileSync( + batchPath, + `${JSON.stringify(createExecDryRunBatch({ markerPath }), null, 2)}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + const before = fs.readFileSync(configPath, "utf8"); + await runConfigSet({ + cliOptions: { + batchFile: batchPath, + dryRun: true, + allowExec: true, + }, + runtime: runtime.runtime, + }); + const after = fs.readFileSync(configPath, "utf8"); + + expect(after).toBe(before); + expect(fs.existsSync(markerPath)).toBe(true); + expect( + runtime.logs.some((line) => + line.includes("Dry run note: skipped 1 exec SecretRef resolvability check(s)."), + ), + ).toBe(false); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 582cd9fd2d3..ded6ad806da 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -386,6 +386,7 @@ describe("config cli", () => { expect(helpText).toContain("--provider-source"); expect(helpText).toContain("--batch-json"); expect(helpText).toContain("--dry-run"); + expect(helpText).toContain("--allow-exec"); expect(helpText).toContain("openclaw config set gateway.port 19001 --strict-json"); expect(helpText).toContain( "openclaw config set channels.discord.token --ref-provider default --ref-source", @@ -556,6 +557,169 @@ describe("config cli", () => { expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1); }); + it("skips exec SecretRef resolvability checks in dry-run by default", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "exec", + command: "/usr/bin/env", + allowInsecurePath: true, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + ]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + "Dry run note: skipped 1 exec SecretRef resolvability check(s). Re-run with --allow-exec", + ), + ); + }); + + it("allows exec SecretRef resolvability checks in dry-run when --allow-exec is set", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "exec", + command: "/usr/bin/env", + allowInsecurePath: true, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + "--allow-exec", + ]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1); + expect(mockResolveSecretRefValue).toHaveBeenCalledWith( + expect.objectContaining({ + source: "exec", + provider: "runner", + id: "openai", + }), + expect.any(Object), + ); + expect(mockLog).not.toHaveBeenCalledWith( + expect.stringContaining("Dry run note: skipped 1 exec SecretRef resolvability check(s)."), + ); + }); + + it("rejects --allow-exec without --dry-run", async () => { + const nonexistentBatchPath = path.join( + os.tmpdir(), + `openclaw-config-batch-nonexistent-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + await expect( + runConfigCommand(["config", "set", "--batch-file", nonexistentBatchPath, "--allow-exec"]), + ).rejects.toThrow("__exit__:1"); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("config set mode error: --allow-exec requires --dry-run."), + ); + }); + + it("fails dry-run when skipped exec refs use an unconfigured provider", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: {}, + }, + }; + setSnapshot(resolved, resolved); + + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining('Secret provider "runner" is not configured'), + ); + }); + + it("fails dry-run when skipped exec refs use a provider with mismatched source", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "env", + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining( + 'Secret provider "runner" has source "env" but ref requests "exec".', + ), + ); + }); + it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 }, @@ -749,19 +913,66 @@ describe("config cli", () => { expect(typeof raw).toBe("string"); const payload = JSON.parse(String(raw)) as { ok: boolean; - checks: { schema: boolean; resolvability: boolean }; + checks: { schema: boolean; resolvability: boolean; resolvabilityComplete: boolean }; refsChecked: number; + skippedExecRefs: number; operations: number; }; expect(payload.ok).toBe(true); expect(payload.operations).toBe(1); expect(payload.refsChecked).toBe(1); + expect(payload.skippedExecRefs).toBe(0); expect(payload.checks).toEqual({ schema: false, resolvability: true, + resolvabilityComplete: true, }); }); + it("emits skipped exec metadata for --dry-run --json success", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "exec", + command: "/usr/bin/env", + allowInsecurePath: true, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + "--json", + ]); + + const raw = mockLog.mock.calls.at(-1)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + ok: boolean; + checks: { resolvability: boolean; resolvabilityComplete: boolean }; + refsChecked: number; + skippedExecRefs: number; + }; + expect(payload.ok).toBe(true); + expect(payload.checks.resolvability).toBe(true); + expect(payload.checks.resolvabilityComplete).toBe(false); + expect(payload.refsChecked).toBe(0); + expect(payload.skippedExecRefs).toBe(1); + }); + it("emits structured JSON for --dry-run --json failure", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 }, diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 0da785a2fd8..8ec98f1804d 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -22,6 +22,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, isValidFileSecretRefId, isValidSecretProviderAlias, secretRefKey, @@ -815,6 +816,66 @@ async function collectDryRunResolvabilityErrors(params: { return failures; } +function collectDryRunStaticErrorsForSkippedExecRefs(params: { + refs: SecretRef[]; + config: OpenClawConfig; +}): ConfigSetDryRunError[] { + const failures: ConfigSetDryRunError[] = []; + for (const ref of params.refs) { + const id = ref.id.trim(); + const refLabel = `${ref.source}:${ref.provider}:${id}`; + if (!id) { + failures.push({ + kind: "resolvability", + message: "Error: Secret reference id is empty.", + ref: refLabel, + }); + continue; + } + if (!isValidExecSecretRefId(id)) { + failures.push({ + kind: "resolvability", + message: `Error: ${formatExecSecretRefIdValidationMessage()} (ref: ${refLabel}).`, + ref: refLabel, + }); + continue; + } + const providerConfig = params.config.secrets?.providers?.[ref.provider]; + if (!providerConfig) { + failures.push({ + kind: "resolvability", + message: `Error: Secret provider "${ref.provider}" is not configured (ref: ${refLabel}).`, + ref: refLabel, + }); + continue; + } + if (providerConfig.source !== ref.source) { + failures.push({ + kind: "resolvability", + message: `Error: Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "${ref.source}".`, + ref: refLabel, + }); + } + } + return failures; +} + +function selectDryRunRefsForResolution(params: { refs: SecretRef[]; allowExecInDryRun: boolean }): { + refsToResolve: SecretRef[]; + skippedExecRefs: SecretRef[]; +} { + const refsToResolve: SecretRef[] = []; + const skippedExecRefs: SecretRef[] = []; + for (const ref of params.refs) { + if (ref.source === "exec" && !params.allowExecInDryRun) { + skippedExecRefs.push(ref); + continue; + } + refsToResolve.push(ref); + } + return { refsToResolve, skippedExecRefs }; +} + function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] { const validated = validateConfigObjectRaw(config); if (validated.ok) { @@ -826,7 +887,11 @@ function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError })); } -function formatDryRunFailureMessage(errors: ConfigSetDryRunError[]): string { +function formatDryRunFailureMessage(params: { + errors: ConfigSetDryRunError[]; + skippedExecRefs: number; +}): string { + const { errors, skippedExecRefs } = params; const schemaErrors = errors.filter((error) => error.kind === "schema"); const resolveErrors = errors.filter((error) => error.kind === "resolvability"); const lines: string[] = []; @@ -847,6 +912,11 @@ function formatDryRunFailureMessage(errors: ConfigSetDryRunError[]): string { lines.push(`- ... ${resolveErrors.length - 5} more`); } } + if (skippedExecRefs > 0) { + lines.push( + `Dry run note: skipped ${skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`, + ); + } return lines.join("\n"); } @@ -868,6 +938,9 @@ export async function runConfigSet(opts: { if (!modeResolution.ok) { throw modeError(modeResolution.error); } + if (opts.cliOptions.allowExec && !opts.cliOptions.dryRun) { + throw modeError("--allow-exec requires --dry-run."); + } const batchEntries = parseBatchSource(opts.cliOptions); if (batchEntries) { @@ -903,14 +976,24 @@ export async function runConfigSet(opts: { operations, }) : []; + const selectedDryRunRefs = selectDryRunRefsForResolution({ + refs, + allowExecInDryRun: Boolean(opts.cliOptions.allowExec), + }); const errors: ConfigSetDryRunError[] = []; if (hasJsonMode) { errors.push(...collectDryRunSchemaErrors(nextConfig)); } if (hasJsonMode || hasBuilderMode) { + errors.push( + ...collectDryRunStaticErrorsForSkippedExecRefs({ + refs: selectedDryRunRefs.skippedExecRefs, + config: nextConfig, + }), + ); errors.push( ...(await collectDryRunResolvabilityErrors({ - refs, + refs: selectedDryRunRefs.refsToResolve, config: nextConfig, })), ); @@ -923,15 +1006,23 @@ export async function runConfigSet(opts: { checks: { schema: hasJsonMode, resolvability: hasJsonMode || hasBuilderMode, + resolvabilityComplete: + (hasJsonMode || hasBuilderMode) && selectedDryRunRefs.skippedExecRefs.length === 0, }, - refsChecked: refs.length, + refsChecked: selectedDryRunRefs.refsToResolve.length, + skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, ...(errors.length > 0 ? { errors } : {}), }; if (errors.length > 0) { if (opts.cliOptions.json) { throw new ConfigSetDryRunValidationError(dryRunResult); } - throw new Error(formatDryRunFailureMessage(errors)); + throw new Error( + formatDryRunFailureMessage({ + errors, + skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, + }), + ); } if (opts.cliOptions.json) { runtime.log(JSON.stringify(dryRunResult, null, 2)); @@ -943,6 +1034,13 @@ export async function runConfigSet(opts: { ), ); } + if (dryRunResult.skippedExecRefs > 0) { + runtime.log( + info( + `Dry run note: skipped ${dryRunResult.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`, + ), + ); + } runtime.log( info( `Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`, @@ -1133,7 +1231,12 @@ export function registerConfigCli(program: Command) { .option("--json", "Legacy alias for --strict-json", false) .option( "--dry-run", - "Validate changes without writing openclaw.json (checks run in builder/json/batch modes)", + "Validate changes without writing openclaw.json (checks run in builder/json/batch modes; exec SecretRefs are skipped unless --allow-exec is set)", + false, + ) + .option( + "--allow-exec", + "Dry-run only: allow exec SecretRef resolvability checks (may execute provider commands)", false, ) .option("--ref-provider ", "SecretRef builder: provider alias") diff --git a/src/cli/config-set-dryrun.ts b/src/cli/config-set-dryrun.ts index c122a47b33f..d121f25eab1 100644 --- a/src/cli/config-set-dryrun.ts +++ b/src/cli/config-set-dryrun.ts @@ -14,7 +14,9 @@ export type ConfigSetDryRunResult = { checks: { schema: boolean; resolvability: boolean; + resolvabilityComplete: boolean; }; refsChecked: number; + skippedExecRefs: number; errors?: ConfigSetDryRunError[]; }; diff --git a/src/cli/config-set-input.ts b/src/cli/config-set-input.ts index b5de984fcdd..b192422288f 100644 --- a/src/cli/config-set-input.ts +++ b/src/cli/config-set-input.ts @@ -5,6 +5,7 @@ export type ConfigSetOptions = { strictJson?: boolean; json?: boolean; dryRun?: boolean; + allowExec?: boolean; refProvider?: string; refSource?: string; refId?: string; From d073ec42cd7fabd1004f6959628743817a4cb0e8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 01:20:04 +0000 Subject: [PATCH 10/31] Tests: reuse embedded runner harness imports --- src/agents/pi-embedded-runner/compact.hooks.test.ts | 9 +++++++-- .../run.overflow-compaction.test.ts | 11 +++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index c5806609c0d..1a97501959e 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -1,5 +1,5 @@ import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; import { contextEngineCompactMock, @@ -13,6 +13,7 @@ import { resolveMemorySearchConfigMock, resolveModelMock, resolveSessionAgentIdMock, + resetCompactHooksHarnessMocks, sanitizeSessionHistoryMock, sessionAbortCompactionMock, sessionCompactImpl, @@ -103,13 +104,17 @@ const sessionHook = (action: string): SessionHookEvent | undefined => return event?.type === "session" && event.action === action; })?.[0] as SessionHookEvent | undefined; -beforeEach(async () => { +beforeAll(async () => { const loaded = await loadCompactHooksHarness(); compactEmbeddedPiSessionDirect = loaded.compactEmbeddedPiSessionDirect; compactEmbeddedPiSession = loaded.compactEmbeddedPiSession; onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate; }); +beforeEach(() => { + resetCompactHooksHarnessMocks(); +}); + describe("compactEmbeddedPiSessionDirect hooks", () => { beforeEach(() => { ensureRuntimePluginsLoaded.mockReset(); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 75a9ab6e034..1f5f0b6de35 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult, makeCompactionSuccess, @@ -16,6 +16,7 @@ import { mockedContextEngine, mockedCompactDirect, mockedRunEmbeddedAttempt, + resetRunOverflowCompactionHarnessMocks, mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, overflowBaseRunParams, @@ -24,10 +25,12 @@ import { let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - return loadRunOverflowCompactionHarness().then((loaded) => { - runEmbeddedPiAgent = loaded.runEmbeddedPiAgent; - }); + resetRunOverflowCompactionHarnessMocks(); }); beforeEach(() => { From d3fc6c0cc79fa347b1bbd9cc5566bc5319187bee Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 19:05:51 -0700 Subject: [PATCH 11/31] Plugins: internalize mattermost and tlon SDK imports --- extensions/mattermost/runtime-api.ts | 1 + extensions/mattermost/src/channel.ts | 2 +- extensions/mattermost/src/config-schema.ts | 2 +- extensions/mattermost/src/group-mentions.ts | 2 +- extensions/mattermost/src/mattermost/accounts.ts | 2 +- extensions/mattermost/src/mattermost/directory.ts | 2 +- extensions/mattermost/src/mattermost/interactions.ts | 2 +- extensions/mattermost/src/mattermost/model-picker.ts | 2 +- extensions/mattermost/src/mattermost/monitor-auth.ts | 4 ++-- extensions/mattermost/src/mattermost/monitor-helpers.ts | 4 ++-- .../mattermost/src/mattermost/monitor-websocket.ts | 2 +- extensions/mattermost/src/mattermost/monitor.ts | 4 ++-- extensions/mattermost/src/mattermost/probe.ts | 2 +- extensions/mattermost/src/mattermost/reactions.ts | 2 +- extensions/mattermost/src/mattermost/reply-delivery.ts | 4 ++-- extensions/mattermost/src/mattermost/runtime-api.ts | 1 + extensions/mattermost/src/mattermost/send.ts | 2 +- extensions/mattermost/src/mattermost/slash-http.ts | 2 +- extensions/mattermost/src/mattermost/slash-state.ts | 6 +++--- .../mattermost/src/mattermost/target-resolution.ts | 2 +- extensions/mattermost/src/runtime-api.ts | 1 + extensions/mattermost/src/runtime.ts | 2 +- extensions/mattermost/src/secret-input.ts | 2 +- extensions/mattermost/src/setup-core.ts | 2 +- extensions/mattermost/src/setup-surface.ts | 2 +- extensions/mattermost/src/types.ts | 2 +- extensions/tlon/api.ts | 1 + extensions/tlon/src/channel.runtime.ts | 9 +++------ extensions/tlon/src/channel.ts | 4 ++-- extensions/tlon/src/config-schema.ts | 2 +- extensions/tlon/src/monitor/discovery.ts | 2 +- extensions/tlon/src/monitor/history.ts | 2 +- extensions/tlon/src/monitor/index.ts | 4 ++-- extensions/tlon/src/monitor/media.ts | 2 +- extensions/tlon/src/monitor/processed-messages.ts | 2 +- extensions/tlon/src/runtime.ts | 2 +- extensions/tlon/src/types.ts | 2 +- extensions/tlon/src/urbit/auth.ts | 2 +- extensions/tlon/src/urbit/base-url.ts | 2 +- extensions/tlon/src/urbit/channel-ops.ts | 2 +- extensions/tlon/src/urbit/context.ts | 2 +- extensions/tlon/src/urbit/fetch.ts | 4 ++-- extensions/tlon/src/urbit/sse-client.ts | 2 +- extensions/tlon/src/urbit/upload.ts | 2 +- src/plugin-sdk/channel-import-guardrails.test.ts | 2 ++ 45 files changed, 57 insertions(+), 54 deletions(-) create mode 100644 extensions/mattermost/runtime-api.ts create mode 100644 extensions/mattermost/src/mattermost/runtime-api.ts create mode 100644 extensions/mattermost/src/runtime-api.ts diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts new file mode 100644 index 00000000000..e13fee5ad71 --- /dev/null +++ b/extensions/mattermost/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/mattermost"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 964310bcbdd..90c7b718639 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -17,7 +17,7 @@ import { type ChannelMessageActionAdapter, type ChannelMessageActionName, type ChannelPlugin, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index d578de86e9a..bd1f42dfd7f 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -4,7 +4,7 @@ import { GroupPolicySchema, MarkdownConfigSchema, requireOpenAllowFrom, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { z } from "zod"; import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { buildSecretInputSchema } from "./secret-input.js"; diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts index 153edc2c84c..4996d115371 100644 --- a/extensions/mattermost/src/group-mentions.ts +++ b/extensions/mattermost/src/group-mentions.ts @@ -1,5 +1,5 @@ import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; -import type { ChannelGroupContext } from "openclaw/plugin-sdk/mattermost"; +import type { ChannelGroupContext } from "./runtime-api.js"; import { resolveMattermostAccount } from "./mattermost/accounts.js"; export function resolveMattermostGroupRequireMention( diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index ae154ba8923..7f2b3ff4175 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -1,5 +1,5 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js"; import type { MattermostAccountConfig, diff --git a/extensions/mattermost/src/mattermost/directory.ts b/extensions/mattermost/src/mattermost/directory.ts index 1b9d3e91e86..630ed7c7194 100644 --- a/extensions/mattermost/src/mattermost/directory.ts +++ b/extensions/mattermost/src/mattermost/directory.ts @@ -2,7 +2,7 @@ import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index f4ef06cf1ed..a51002667f8 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -4,7 +4,7 @@ import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { getMattermostRuntime } from "../runtime.js"; import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts index 1547041a74a..925308b04cc 100644 --- a/extensions/mattermost/src/mattermost/model-picker.ts +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -6,7 +6,7 @@ import { resolveStoredModelOverride, type ModelsProviderData, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import type { MattermostInteractiveButtonInput } from "./interactions.js"; const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker"; diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts index 7f263cd09b5..e83f06b8ba6 100644 --- a/extensions/mattermost/src/mattermost/monitor-auth.ts +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -1,11 +1,11 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig } from "../runtime-api.js"; import { evaluateSenderGroupAccessForPolicy, isDangerousNameMatchingEnabled, resolveAllowlistMatchSimple, resolveControlCommandGate, resolveEffectiveAllowFromLists, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; import type { MattermostChannel } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index 219c0562638..d6ce7ee4aa9 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -2,8 +2,8 @@ import { formatInboundFromLabel as formatInboundFromLabelShared, resolveThreadSessionKeys as resolveThreadSessionKeysShared, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; -export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; +export { createDedupeCache, rawDataToString } from "../runtime-api.js"; export type ResponsePrefixContext = { model?: string; diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.ts b/extensions/mattermost/src/mattermost/monitor-websocket.ts index 7f04a18f09b..c04affbae1d 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.ts @@ -1,4 +1,4 @@ -import type { ChannelAccountSnapshot, RuntimeEnv } from "openclaw/plugin-sdk/mattermost"; +import type { ChannelAccountSnapshot, RuntimeEnv } from "../runtime-api.js"; import WebSocket from "ws"; import type { MattermostPost } from "./client.js"; import { rawDataToString } from "./monitor-helpers.js"; diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index e56e4a9b9af..a849cf52160 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig, ReplyPayload, RuntimeEnv, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { buildAgentMediaPayload, buildModelsProviderData, @@ -30,7 +30,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, listSkillCommandsForAgents, type HistoryEntry, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js"; import { diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts index 2966e20f209..d3ee56ab3a0 100644 --- a/extensions/mattermost/src/mattermost/probe.ts +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/mattermost"; +import type { BaseProbeResult } from "../runtime-api.js"; import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js"; export type MattermostProbe = BaseProbeResult & { diff --git a/extensions/mattermost/src/mattermost/reactions.ts b/extensions/mattermost/src/mattermost/reactions.ts index 3515153edd2..42de67b4e10 100644 --- a/extensions/mattermost/src/mattermost/reactions.ts +++ b/extensions/mattermost/src/mattermost/reactions.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig } from "../runtime-api.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, fetchMattermostMe, type MattermostClient } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts index 5c94e51934b..6fc88c8ba83 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "openclaw/plugin-sdk/mattermost"; -import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js"; +import { getAgentScopedMediaLocalRoots } from "../runtime-api.js"; type MarkdownTableMode = Parameters[1]; diff --git a/extensions/mattermost/src/mattermost/runtime-api.ts b/extensions/mattermost/src/mattermost/runtime-api.ts new file mode 100644 index 00000000000..cb133391638 --- /dev/null +++ b/extensions/mattermost/src/mattermost/runtime-api.ts @@ -0,0 +1 @@ +export * from "../../runtime-api.js"; diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index c589c8829a0..e6bbdf2298a 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -1,4 +1,4 @@ -import { loadOutboundMediaFromUrl, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { loadOutboundMediaFromUrl, type OpenClawConfig } from "../runtime-api.js"; import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; import { diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index a094b3571ff..401cc56172a 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -16,7 +16,7 @@ import { type OpenClawConfig, type ReplyPayload, type RuntimeEnv, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { getMattermostRuntime } from "../runtime.js"; import { diff --git a/extensions/mattermost/src/mattermost/slash-state.ts b/extensions/mattermost/src/mattermost/slash-state.ts index f79f670df8d..8e5fe1f08b3 100644 --- a/extensions/mattermost/src/mattermost/slash-state.ts +++ b/extensions/mattermost/src/mattermost/slash-state.ts @@ -10,7 +10,7 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js"; import { createSlashCommandHttpHandler } from "./slash-http.js"; @@ -86,8 +86,8 @@ export function activateSlashCommands(params: { registeredCommands: MattermostRegisteredCommand[]; triggerMap?: Map; api: { - cfg: import("openclaw/plugin-sdk/mattermost").OpenClawConfig; - runtime: import("openclaw/plugin-sdk/mattermost").RuntimeEnv; + cfg: import("../runtime-api.js").OpenClawConfig; + runtime: import("../runtime-api.js").RuntimeEnv; }; log?: (msg: string) => void; }) { diff --git a/extensions/mattermost/src/mattermost/target-resolution.ts b/extensions/mattermost/src/mattermost/target-resolution.ts index d3b59a3e696..9fa1a170ca3 100644 --- a/extensions/mattermost/src/mattermost/target-resolution.ts +++ b/extensions/mattermost/src/mattermost/target-resolution.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig } from "../runtime-api.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, diff --git a/extensions/mattermost/src/runtime-api.ts b/extensions/mattermost/src/runtime-api.ts new file mode 100644 index 00000000000..ece735819df --- /dev/null +++ b/extensions/mattermost/src/runtime-api.ts @@ -0,0 +1 @@ +export * from "../runtime-api.js"; diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts index b5ec1942973..e238fa963e2 100644 --- a/extensions/mattermost/src/runtime.ts +++ b/extensions/mattermost/src/runtime.ts @@ -1,4 +1,4 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost"; +import type { PluginRuntime } from "./runtime-api.js"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = diff --git a/extensions/mattermost/src/secret-input.ts b/extensions/mattermost/src/secret-input.ts index 576f5b9fc45..b32083456e7 100644 --- a/extensions/mattermost/src/secret-input.ts +++ b/extensions/mattermost/src/secret-input.ts @@ -3,7 +3,7 @@ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; export { buildSecretInputSchema, diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 781967c70a6..13a4991fcd0 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -7,7 +7,7 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index d3b0a66b4c8..385c4dc75e3 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -3,7 +3,7 @@ import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup"; import { listMattermostAccountIds } from "./mattermost/accounts.js"; diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index e6fcc19098c..b77a542122b 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -3,7 +3,7 @@ import type { DmPolicy, GroupPolicy, SecretInput, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; export type MattermostReplyToMode = "off" | "first" | "all"; export type MattermostChatTypeKey = "direct" | "channel" | "group"; diff --git a/extensions/tlon/api.ts b/extensions/tlon/api.ts index 8f7fe4d268b..ca61d62ee69 100644 --- a/extensions/tlon/api.ts +++ b/extensions/tlon/api.ts @@ -1,2 +1,3 @@ +export * from "openclaw/plugin-sdk/tlon"; export * from "./src/setup-core.js"; export * from "./src/setup-surface.js"; diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index 525359a2a4e..c6523f61739 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -1,10 +1,7 @@ import crypto from "node:crypto"; import { configureClient } from "@tloncorp/api"; -import type { - ChannelOutboundAdapter, - ChannelPlugin, - OpenClawConfig, -} from "openclaw/plugin-sdk/tlon"; +import type { ChannelOutboundAdapter, ChannelPlugin, OpenClawConfig } from "../api.js"; +import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../api.js"; import { monitorTlonProvider } from "./monitor/index.js"; import { tlonSetupWizard } from "./setup-surface.js"; import { @@ -230,7 +227,7 @@ export async function startTlonGatewayAccount( accountId: account.accountId, ship: account.ship, url: account.url, - } as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot); + } as ChannelAccountSnapshot); ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`); return monitorTlonProvider({ runtime: ctx.runtime, diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index daea0d8a52e..0e22d237589 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,5 +1,4 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/tlon"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { applyTlonSetupConfig, @@ -14,6 +13,7 @@ import { resolveTlonOutboundTarget, } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; +import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "../api.js"; import { validateUrbitBaseUrl } from "./urbit/base-url.js"; const TLON_CHANNEL_ID = "tlon" as const; @@ -214,7 +214,7 @@ export const tlonPlugin: ChannelPlugin = { lastError: runtime?.lastError ?? null, probe, }; - return snapshot as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot; + return snapshot as ChannelAccountSnapshot; }, }, gateway: { diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 666f65e35da..7f12949f30d 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -1,4 +1,4 @@ -import { buildChannelConfigSchema } from "openclaw/plugin-sdk/tlon"; +import { buildChannelConfigSchema } from "../api.js"; import { z } from "zod"; const ShipSchema = z.string().min(1); diff --git a/extensions/tlon/src/monitor/discovery.ts b/extensions/tlon/src/monitor/discovery.ts index a7224608bf0..66ec43a2680 100644 --- a/extensions/tlon/src/monitor/discovery.ts +++ b/extensions/tlon/src/monitor/discovery.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/tlon"; +import type { RuntimeEnv } from "../../api.js"; import type { Foreigns } from "../urbit/foreigns.js"; import { formatChangesDate } from "./utils.js"; diff --git a/extensions/tlon/src/monitor/history.ts b/extensions/tlon/src/monitor/history.ts index a67fae7ada4..0ebfc6e231c 100644 --- a/extensions/tlon/src/monitor/history.ts +++ b/extensions/tlon/src/monitor/history.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/tlon"; +import type { RuntimeEnv } from "../../api.js"; import { extractMessageText } from "./utils.js"; /** diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 19c9ec5b841..e7749010462 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,5 +1,5 @@ -import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk/tlon"; -import { createLoggerBackedRuntime, createReplyPrefixOptions } from "openclaw/plugin-sdk/tlon"; +import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "../../api.js"; +import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../../api.js"; import { getTlonRuntime } from "../runtime.js"; import { createSettingsManager, type TlonSettingsStore } from "../settings.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; diff --git a/extensions/tlon/src/monitor/media.ts b/extensions/tlon/src/monitor/media.ts index 588598e4d2d..ea86328d2ce 100644 --- a/extensions/tlon/src/monitor/media.ts +++ b/extensions/tlon/src/monitor/media.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon"; +import { fetchWithSsrFGuard } from "../api.js"; import { getDefaultSsrFPolicy } from "../urbit/context.js"; // Default to OpenClaw workspace media directory diff --git a/extensions/tlon/src/monitor/processed-messages.ts b/extensions/tlon/src/monitor/processed-messages.ts index d849724c4a5..ed5231aa98b 100644 --- a/extensions/tlon/src/monitor/processed-messages.ts +++ b/extensions/tlon/src/monitor/processed-messages.ts @@ -1,4 +1,4 @@ -import { createDedupeCache } from "openclaw/plugin-sdk/tlon"; +import { createDedupeCache } from "../../api.js"; export type ProcessedMessageTracker = { mark: (id?: string | null) => boolean; diff --git a/extensions/tlon/src/runtime.ts b/extensions/tlon/src/runtime.ts index a07eb5cf648..bf284e214a8 100644 --- a/extensions/tlon/src/runtime.ts +++ b/extensions/tlon/src/runtime.ts @@ -1,5 +1,5 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "openclaw/plugin-sdk/tlon"; +import type { PluginRuntime } from "../api.js"; const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } = createPluginRuntimeStore("Tlon runtime not initialized"); diff --git a/extensions/tlon/src/types.ts b/extensions/tlon/src/types.ts index e9bc27ac169..7aa0690c14f 100644 --- a/extensions/tlon/src/types.ts +++ b/extensions/tlon/src/types.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon"; +import type { OpenClawConfig } from "../api.js"; export type TlonResolvedAccount = { accountId: string; diff --git a/extensions/tlon/src/urbit/auth.ts b/extensions/tlon/src/urbit/auth.ts index 3b7ccd16593..687fb0e4121 100644 --- a/extensions/tlon/src/urbit/auth.ts +++ b/extensions/tlon/src/urbit/auth.ts @@ -1,4 +1,4 @@ -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; import { UrbitAuthError } from "./errors.js"; import { urbitFetch } from "./fetch.js"; diff --git a/extensions/tlon/src/urbit/base-url.ts b/extensions/tlon/src/urbit/base-url.ts index e90168b47a9..15321d3e391 100644 --- a/extensions/tlon/src/urbit/base-url.ts +++ b/extensions/tlon/src/urbit/base-url.ts @@ -1,4 +1,4 @@ -import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/tlon"; +import { isBlockedHostnameOrIp } from "../../api.js"; export type UrbitBaseUrlValidation = | { ok: true; baseUrl: string; hostname: string } diff --git a/extensions/tlon/src/urbit/channel-ops.ts b/extensions/tlon/src/urbit/channel-ops.ts index ef65e4ca9fe..98b3981942e 100644 --- a/extensions/tlon/src/urbit/channel-ops.ts +++ b/extensions/tlon/src/urbit/channel-ops.ts @@ -1,4 +1,4 @@ -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; import { UrbitHttpError } from "./errors.js"; import { urbitFetch } from "./fetch.js"; diff --git a/extensions/tlon/src/urbit/context.ts b/extensions/tlon/src/urbit/context.ts index 6fbae002f5d..01b49d94041 100644 --- a/extensions/tlon/src/urbit/context.ts +++ b/extensions/tlon/src/urbit/context.ts @@ -1,4 +1,4 @@ -import type { SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { SsrFPolicy } from "../../api.js"; import { validateUrbitBaseUrl } from "./base-url.js"; import { UrbitUrlError } from "./errors.js"; diff --git a/extensions/tlon/src/urbit/fetch.ts b/extensions/tlon/src/urbit/fetch.ts index a1551df547d..638c70f0840 100644 --- a/extensions/tlon/src/urbit/fetch.ts +++ b/extensions/tlon/src/urbit/fetch.ts @@ -1,5 +1,5 @@ -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; +import { fetchWithSsrFGuard } from "../../api.js"; import { validateUrbitBaseUrl } from "./base-url.js"; import { UrbitUrlError } from "./errors.js"; diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index afa87502320..2fae6b82041 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { Readable } from "node:stream"; -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js"; import { getUrbitContext, normalizeUrbitCookie } from "./context.js"; import { urbitFetch } from "./fetch.js"; diff --git a/extensions/tlon/src/urbit/upload.ts b/extensions/tlon/src/urbit/upload.ts index 81aaef84a06..6176c132207 100644 --- a/extensions/tlon/src/urbit/upload.ts +++ b/extensions/tlon/src/urbit/upload.ts @@ -2,7 +2,7 @@ * Upload an image from a URL to Tlon storage. */ import { uploadFile } from "@tloncorp/api"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon"; +import { fetchWithSsrFGuard } from "../../api.js"; import { getDefaultSsrFPolicy } from "./context.js"; /** diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 0d49e580d11..b953d4d974a 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -122,11 +122,13 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "diffs", "llm-task", "line", + "mattermost", "memory-lancedb", "nextcloud-talk", "synology-chat", "talk-voice", "thread-ownership", + "tlon", "voice-call", ] as const; From 4c36436fb4ddf2126de04acf375c834236ce9104 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:06:29 +0000 Subject: [PATCH 12/31] Plugin SDK: add legacy message discovery helper --- extensions/feishu/src/channel.ts | 109 +++++++++++--------- extensions/mattermost/src/channel.ts | 87 +++++++++------- extensions/msteams/src/channel.ts | 51 +++++---- src/channels/plugins/message-tool-legacy.ts | 13 +++ src/plugin-sdk/channel-runtime.ts | 1 + 5 files changed, 152 insertions(+), 109 deletions(-) create mode 100644 src/channels/plugins/message-tool-legacy.ts diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index ded06f97f53..da5cd8e4382 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,8 +1,14 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { + createLegacyMessageToolDiscoveryMethods, + createMessageToolCardSchema, +} from "openclaw/plugin-sdk/channel-runtime"; +import type { + ChannelMessageActionAdapter, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { buildChannelConfigSchema, @@ -49,6 +55,56 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport( "feishuChannelRuntime", ); +function describeFeishuMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const enabled = + cfg.channels?.feishu?.enabled !== false && + Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)); + if (listEnabledFeishuAccounts(cfg).length === 0) { + return { + actions: [], + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; + } + const actions = new Set([ + "send", + "read", + "edit", + "thread-reply", + "pin", + "list-pins", + "unpin", + "member-info", + "channel-info", + "channel-list", + ]); + if (areAnyFeishuReactionActionsEnabled(cfg)) { + actions.add("react"); + actions.add("reactions"); + } + return { + actions: Array.from(actions), + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; +} + function setFeishuNamedAccountEnabled( cfg: ClawdbotConfig, accountId: string, @@ -396,53 +452,8 @@ export const feishuPlugin: ChannelPlugin = { formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), }, actions: { - describeMessageTool: ({ - cfg, - }: Parameters>[0]) => { - const enabled = - cfg.channels?.feishu?.enabled !== false && - Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)); - if (listEnabledFeishuAccounts(cfg).length === 0) { - return { - actions: [], - capabilities: enabled ? ["cards"] : [], - schema: enabled - ? { - properties: { - card: createMessageToolCardSchema(), - }, - } - : null, - }; - } - const actions = new Set([ - "send", - "read", - "edit", - "thread-reply", - "pin", - "list-pins", - "unpin", - "member-info", - "channel-info", - "channel-list", - ]); - if (areAnyFeishuReactionActionsEnabled(cfg)) { - actions.add("react"); - actions.add("reactions"); - } - return { - actions: Array.from(actions), - capabilities: enabled ? ["cards"] : [], - schema: enabled - ? { - properties: { - card: createMessageToolCardSchema(), - }, - } - : null, - }; - }, + describeMessageTool: describeFeishuMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeFeishuMessageTool), handleAction: async (ctx) => { const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined }); if ( diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 90c7b718639..5688e13d8ae 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -4,7 +4,11 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime"; +import { + createLegacyMessageToolDiscoveryMethods, + createMessageToolButtonsSchema, +} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime"; import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, @@ -42,46 +46,49 @@ import { getMattermostRuntime } from "./runtime.js"; import { mattermostSetupAdapter } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; +function describeMattermostMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const enabledAccounts = listMattermostAccountIds(cfg) + .map((accountId) => resolveMattermostAccount({ cfg, accountId })) + .filter((account) => account.enabled) + .filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim())); + + const actions: ChannelMessageActionName[] = []; + + if (enabledAccounts.length > 0) { + actions.push("send"); + } + + const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined; + const baseReactions = actionsConfig?.reactions; + const hasReactionCapableAccount = enabledAccounts.some((account) => { + const accountActions = account.config.actions as { reactions?: boolean } | undefined; + return (accountActions?.reactions ?? baseReactions ?? true) !== false; + }); + if (hasReactionCapableAccount) { + actions.push("react"); + } + + return { + actions, + capabilities: enabledAccounts.length > 0 ? ["buttons"] : [], + schema: + enabledAccounts.length > 0 + ? { + properties: { + buttons: createMessageToolButtonsSchema(), + }, + } + : null, + }; +} + const mattermostMessageActions: ChannelMessageActionAdapter = { - describeMessageTool: ({ - cfg, - }: Parameters>[0]) => { - const enabledAccounts = listMattermostAccountIds(cfg) - .map((accountId) => resolveMattermostAccount({ cfg, accountId })) - .filter((account) => account.enabled) - .filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim())); - - const actions: ChannelMessageActionName[] = []; - - // Send (buttons) is available whenever there's at least one enabled account - if (enabledAccounts.length > 0) { - actions.push("send"); - } - - // React requires per-account reactions config check - const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined; - const baseReactions = actionsConfig?.reactions; - const hasReactionCapableAccount = enabledAccounts.some((account) => { - const accountActions = account.config.actions as { reactions?: boolean } | undefined; - return (accountActions?.reactions ?? baseReactions ?? true) !== false; - }); - if (hasReactionCapableAccount) { - actions.push("react"); - } - - return { - actions, - capabilities: enabledAccounts.length > 0 ? ["buttons"] : [], - schema: - enabledAccounts.length > 0 - ? { - properties: { - buttons: createMessageToolButtonsSchema(), - }, - } - : null, - }; - }, + describeMessageTool: describeMattermostMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeMattermostMessageTool), supportsAction: ({ action }) => { return action === "send" || action === "react"; }, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 827507c24f2..7458389efb1 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,7 +1,13 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { + createLegacyMessageToolDiscoveryMethods, + createMessageToolCardSchema, +} from "openclaw/plugin-sdk/channel-runtime"; +import type { + ChannelMessageActionAdapter, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, @@ -64,6 +70,27 @@ const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( "msTeamsChannelRuntime", ); +function describeMSTeamsMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const enabled = + cfg.channels?.msteams?.enabled !== false && + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)); + return { + actions: enabled ? (["poll"] satisfies ChannelMessageActionName[]) : [], + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; +} + export const msteamsPlugin: ChannelPlugin = { id: "msteams", meta: { @@ -370,24 +397,8 @@ export const msteamsPlugin: ChannelPlugin = { }, }, actions: { - describeMessageTool: ({ - cfg, - }: Parameters>[0]) => { - const enabled = - cfg.channels?.msteams?.enabled !== false && - Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)); - return { - actions: enabled ? (["poll"] satisfies ChannelMessageActionName[]) : [], - capabilities: enabled ? ["cards"] : [], - schema: enabled - ? { - properties: { - card: createMessageToolCardSchema(), - }, - } - : null, - }; - }, + describeMessageTool: describeMSTeamsMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeMSTeamsMessageTool), handleAction: async (ctx) => { // Handle send action with card parameter if (ctx.action === "send" && ctx.params.card) { diff --git a/src/channels/plugins/message-tool-legacy.ts b/src/channels/plugins/message-tool-legacy.ts new file mode 100644 index 00000000000..2c74213439f --- /dev/null +++ b/src/channels/plugins/message-tool-legacy.ts @@ -0,0 +1,13 @@ +import type { ChannelMessageActionAdapter } from "./types.js"; + +export function createLegacyMessageToolDiscoveryMethods( + describeMessageTool: NonNullable, +): Pick { + const describe = (ctx: Parameters[0]) => + describeMessageTool(ctx) ?? null; + return { + listActions: (ctx) => [...(describe(ctx)?.actions ?? [])], + getCapabilities: (ctx) => [...(describe(ctx)?.capabilities ?? [])], + getToolSchema: (ctx) => describe(ctx)?.schema ?? null, + }; +} diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index 089e10609af..1460acba87d 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -34,6 +34,7 @@ export type * from "../channels/plugins/types.js"; export * from "../channels/plugins/config-writes.js"; export * from "../channels/plugins/directory-config.js"; export * from "../channels/plugins/media-payload.js"; +export * from "../channels/plugins/message-tool-legacy.js"; export * from "../channels/plugins/message-tool-schema.js"; export * from "../channels/plugins/normalize/signal.js"; export * from "../channels/plugins/normalize/whatsapp.js"; From 9df3e9b617b2251462a9f9c8c96d31ec76fceca4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:07:01 +0000 Subject: [PATCH 13/31] Discord: move action runtime into extension --- extensions/discord/runtime-api.ts | 3 + .../src/actions/handle-action.guild-admin.ts | 6 +- .../discord/src/actions/handle-action.ts | 4 +- .../discord/src/actions/runtime.guild.ts | 139 +++++++---- .../discord/src/actions/runtime.messaging.ts | 235 ++++++++++++------ .../src/actions/runtime.moderation-shared.ts | 2 +- .../actions/runtime.moderation.authz.test.ts | 37 +-- .../discord/src/actions/runtime.moderation.ts | 33 ++- .../src/actions/runtime.presence.test.ts | 11 +- .../discord/src/actions/runtime.presence.ts | 10 +- .../discord/src/actions/runtime.shared.ts | 2 +- .../discord/src/actions/runtime.test.ts | 48 ++-- .../discord/src/actions/runtime.ts | 14 +- extensions/discord/src/channel-actions.ts | 2 + src/plugin-sdk/agent-runtime.ts | 12 +- 15 files changed, 363 insertions(+), 195 deletions(-) rename src/agents/tools/discord-actions-guild.ts => extensions/discord/src/actions/runtime.guild.ts (78%) rename src/agents/tools/discord-actions-messaging.ts => extensions/discord/src/actions/runtime.messaging.ts (71%) rename src/agents/tools/discord-actions-moderation-shared.ts => extensions/discord/src/actions/runtime.moderation-shared.ts (94%) rename src/agents/tools/discord-actions-moderation.authz.test.ts => extensions/discord/src/actions/runtime.moderation.authz.test.ts (81%) rename src/agents/tools/discord-actions-moderation.ts => extensions/discord/src/actions/runtime.moderation.ts (78%) rename src/agents/tools/discord-actions-presence.test.ts => extensions/discord/src/actions/runtime.presence.test.ts (94%) rename src/agents/tools/discord-actions-presence.ts => extensions/discord/src/actions/runtime.presence.ts (92%) rename src/agents/tools/discord-actions-shared.ts => extensions/discord/src/actions/runtime.shared.ts (78%) rename src/agents/tools/discord-actions.test.ts => extensions/discord/src/actions/runtime.test.ts (94%) rename src/agents/tools/discord-actions.ts => extensions/discord/src/actions/runtime.ts (79%) diff --git a/extensions/discord/runtime-api.ts b/extensions/discord/runtime-api.ts index 3850143c4ef..938b03d9c4a 100644 --- a/extensions/discord/runtime-api.ts +++ b/extensions/discord/runtime-api.ts @@ -1,4 +1,7 @@ export * from "./src/audit.js"; +export * from "./src/actions/runtime.js"; +export * from "./src/actions/runtime.moderation-shared.js"; +export * from "./src/actions/runtime.shared.js"; export * from "./src/channel-actions.js"; export * from "./src/directory-live.js"; export * from "./src/monitor.js"; diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts index 0f6075384a5..e63d00f23ec 100644 --- a/extensions/discord/src/actions/handle-action.guild-admin.ts +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -5,12 +5,12 @@ import { readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import { handleDiscordAction } from "./runtime.js"; import { isDiscordModerationAction, readDiscordModerationCommand, -} from "openclaw/plugin-sdk/agent-runtime"; -import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +} from "./runtime.moderation-shared.js"; type Ctx = Pick< ChannelMessageActionContext, diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index d23b078292a..0fca934e86f 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -4,8 +4,6 @@ import { readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; -import { readDiscordParentIdParam } from "openclaw/plugin-sdk/agent-runtime"; -import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; @@ -13,6 +11,8 @@ import { normalizeInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; import { buildDiscordInteractiveComponents } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; +import { handleDiscordAction } from "./runtime.js"; +import { readDiscordParentIdParam } from "./runtime.shared.js"; const providerId = "discord"; diff --git a/src/agents/tools/discord-actions-guild.ts b/extensions/discord/src/actions/runtime.guild.ts similarity index 78% rename from src/agents/tools/discord-actions-guild.ts rename to extensions/discord/src/actions/runtime.guild.ts index fa427d87650..5b3ed54dc83 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/extensions/discord/src/actions/runtime.guild.ts @@ -1,5 +1,14 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; +import { + type ActionGate, + jsonResult, + parseAvailableTags, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { getPresence } from "../monitor/presence-cache.js"; import { addRoleDiscord, createChannelDiscord, @@ -19,17 +28,29 @@ import { setChannelPermissionDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../plugin-sdk/discord.js"; -import { getPresence } from "../../plugin-sdk/discord.js"; -import { - type ActionGate, - jsonResult, - parseAvailableTags, - readNumberParam, - readStringArrayParam, - readStringParam, -} from "./common.js"; -import { readDiscordParentIdParam } from "./discord-actions-shared.js"; +} from "../send.js"; +import { readDiscordParentIdParam } from "./runtime.shared.js"; + +export const discordGuildActionRuntime = { + addRoleDiscord, + createChannelDiscord, + createScheduledEventDiscord, + deleteChannelDiscord, + editChannelDiscord, + fetchChannelInfoDiscord, + fetchMemberInfoDiscord, + fetchRoleInfoDiscord, + fetchVoiceStatusDiscord, + listGuildChannelsDiscord, + listGuildEmojisDiscord, + listScheduledEventsDiscord, + moveChannelDiscord, + removeChannelPermissionDiscord, + removeRoleDiscord, + setChannelPermissionDiscord, + uploadEmojiDiscord, + uploadStickerDiscord, +}; type DiscordRoleMutation = (params: { guildId: string; @@ -85,8 +106,8 @@ export async function handleDiscordGuildAction( required: true, }); const member = accountId - ? await fetchMemberInfoDiscord(guildId, userId, { accountId }) - : await fetchMemberInfoDiscord(guildId, userId); + ? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, { accountId }) + : await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId); const presence = getPresence(accountId, userId); const activities = presence?.activities ?? undefined; const status = presence?.status ?? undefined; @@ -100,8 +121,8 @@ export async function handleDiscordGuildAction( required: true, }); const roles = accountId - ? await fetchRoleInfoDiscord(guildId, { accountId }) - : await fetchRoleInfoDiscord(guildId); + ? await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId); return jsonResult({ ok: true, roles }); } case "emojiList": { @@ -112,8 +133,8 @@ export async function handleDiscordGuildAction( required: true, }); const emojis = accountId - ? await listGuildEmojisDiscord(guildId, { accountId }) - : await listGuildEmojisDiscord(guildId); + ? await discordGuildActionRuntime.listGuildEmojisDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.listGuildEmojisDiscord(guildId); return jsonResult({ ok: true, emojis }); } case "emojiUpload": { @@ -129,7 +150,7 @@ export async function handleDiscordGuildAction( }); const roleIds = readStringArrayParam(params, "roleIds"); const emoji = accountId - ? await uploadEmojiDiscord( + ? await discordGuildActionRuntime.uploadEmojiDiscord( { guildId, name, @@ -138,7 +159,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await uploadEmojiDiscord({ + : await discordGuildActionRuntime.uploadEmojiDiscord({ guildId, name, mediaUrl, @@ -162,7 +183,7 @@ export async function handleDiscordGuildAction( required: true, }); const sticker = accountId - ? await uploadStickerDiscord( + ? await discordGuildActionRuntime.uploadStickerDiscord( { guildId, name, @@ -172,7 +193,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await uploadStickerDiscord({ + : await discordGuildActionRuntime.uploadStickerDiscord({ guildId, name, description, @@ -185,14 +206,22 @@ export async function handleDiscordGuildAction( if (!isActionEnabled("roles", false)) { throw new Error("Discord role changes are disabled."); } - await runRoleMutation({ accountId, values: params, mutate: addRoleDiscord }); + await runRoleMutation({ + accountId, + values: params, + mutate: discordGuildActionRuntime.addRoleDiscord, + }); return jsonResult({ ok: true }); } case "roleRemove": { if (!isActionEnabled("roles", false)) { throw new Error("Discord role changes are disabled."); } - await runRoleMutation({ accountId, values: params, mutate: removeRoleDiscord }); + await runRoleMutation({ + accountId, + values: params, + mutate: discordGuildActionRuntime.removeRoleDiscord, + }); return jsonResult({ ok: true }); } case "channelInfo": { @@ -203,8 +232,8 @@ export async function handleDiscordGuildAction( required: true, }); const channel = accountId - ? await fetchChannelInfoDiscord(channelId, { accountId }) - : await fetchChannelInfoDiscord(channelId); + ? await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId, { accountId }) + : await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId); return jsonResult({ ok: true, channel }); } case "channelList": { @@ -215,8 +244,8 @@ export async function handleDiscordGuildAction( required: true, }); const channels = accountId - ? await listGuildChannelsDiscord(guildId, { accountId }) - : await listGuildChannelsDiscord(guildId); + ? await discordGuildActionRuntime.listGuildChannelsDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.listGuildChannelsDiscord(guildId); return jsonResult({ ok: true, channels }); } case "voiceStatus": { @@ -230,8 +259,10 @@ export async function handleDiscordGuildAction( required: true, }); const voice = accountId - ? await fetchVoiceStatusDiscord(guildId, userId, { accountId }) - : await fetchVoiceStatusDiscord(guildId, userId); + ? await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId, { + accountId, + }) + : await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId); return jsonResult({ ok: true, voice }); } case "eventList": { @@ -242,8 +273,8 @@ export async function handleDiscordGuildAction( required: true, }); const events = accountId - ? await listScheduledEventsDiscord(guildId, { accountId }) - : await listScheduledEventsDiscord(guildId); + ? await discordGuildActionRuntime.listScheduledEventsDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.listScheduledEventsDiscord(guildId); return jsonResult({ ok: true, events }); } case "eventCreate": { @@ -274,8 +305,10 @@ export async function handleDiscordGuildAction( privacy_level: 2, }; const event = accountId - ? await createScheduledEventDiscord(guildId, payload, { accountId }) - : await createScheduledEventDiscord(guildId, payload); + ? await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload, { + accountId, + }) + : await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload); return jsonResult({ ok: true, event }); } case "channelCreate": { @@ -290,7 +323,7 @@ export async function handleDiscordGuildAction( const position = readNumberParam(params, "position", { integer: true }); const nsfw = params.nsfw as boolean | undefined; const channel = accountId - ? await createChannelDiscord( + ? await discordGuildActionRuntime.createChannelDiscord( { guildId, name, @@ -302,7 +335,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await createChannelDiscord({ + : await discordGuildActionRuntime.createChannelDiscord({ guildId, name, type: type ?? undefined, @@ -348,8 +381,8 @@ export async function handleDiscordGuildAction( availableTags, }; const channel = accountId - ? await editChannelDiscord(editPayload, { accountId }) - : await editChannelDiscord(editPayload); + ? await discordGuildActionRuntime.editChannelDiscord(editPayload, { accountId }) + : await discordGuildActionRuntime.editChannelDiscord(editPayload); return jsonResult({ ok: true, channel }); } case "channelDelete": { @@ -360,8 +393,8 @@ export async function handleDiscordGuildAction( required: true, }); const result = accountId - ? await deleteChannelDiscord(channelId, { accountId }) - : await deleteChannelDiscord(channelId); + ? await discordGuildActionRuntime.deleteChannelDiscord(channelId, { accountId }) + : await discordGuildActionRuntime.deleteChannelDiscord(channelId); return jsonResult(result); } case "channelMove": { @@ -375,7 +408,7 @@ export async function handleDiscordGuildAction( const parentId = readDiscordParentIdParam(params); const position = readNumberParam(params, "position", { integer: true }); if (accountId) { - await moveChannelDiscord( + await discordGuildActionRuntime.moveChannelDiscord( { guildId, channelId, @@ -385,7 +418,7 @@ export async function handleDiscordGuildAction( { accountId }, ); } else { - await moveChannelDiscord({ + await discordGuildActionRuntime.moveChannelDiscord({ guildId, channelId, parentId, @@ -402,7 +435,7 @@ export async function handleDiscordGuildAction( const name = readStringParam(params, "name", { required: true }); const position = readNumberParam(params, "position", { integer: true }); const channel = accountId - ? await createChannelDiscord( + ? await discordGuildActionRuntime.createChannelDiscord( { guildId, name, @@ -411,7 +444,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await createChannelDiscord({ + : await discordGuildActionRuntime.createChannelDiscord({ guildId, name, type: 4, @@ -429,7 +462,7 @@ export async function handleDiscordGuildAction( const name = readStringParam(params, "name"); const position = readNumberParam(params, "position", { integer: true }); const channel = accountId - ? await editChannelDiscord( + ? await discordGuildActionRuntime.editChannelDiscord( { channelId: categoryId, name: name ?? undefined, @@ -437,7 +470,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await editChannelDiscord({ + : await discordGuildActionRuntime.editChannelDiscord({ channelId: categoryId, name: name ?? undefined, position: position ?? undefined, @@ -452,8 +485,8 @@ export async function handleDiscordGuildAction( required: true, }); const result = accountId - ? await deleteChannelDiscord(categoryId, { accountId }) - : await deleteChannelDiscord(categoryId); + ? await discordGuildActionRuntime.deleteChannelDiscord(categoryId, { accountId }) + : await discordGuildActionRuntime.deleteChannelDiscord(categoryId); return jsonResult(result); } case "channelPermissionSet": { @@ -468,7 +501,7 @@ export async function handleDiscordGuildAction( const allow = readStringParam(params, "allow"); const deny = readStringParam(params, "deny"); if (accountId) { - await setChannelPermissionDiscord( + await discordGuildActionRuntime.setChannelPermissionDiscord( { channelId, targetId, @@ -479,7 +512,7 @@ export async function handleDiscordGuildAction( { accountId }, ); } else { - await setChannelPermissionDiscord({ + await discordGuildActionRuntime.setChannelPermissionDiscord({ channelId, targetId, targetType, @@ -495,9 +528,11 @@ export async function handleDiscordGuildAction( } const { channelId, targetId } = readChannelPermissionTarget(params); if (accountId) { - await removeChannelPermissionDiscord(channelId, targetId, { accountId }); + await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId, { + accountId, + }); } else { - await removeChannelPermissionDiscord(channelId, targetId); + await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId); } return jsonResult({ ok: true }); } diff --git a/src/agents/tools/discord-actions-messaging.ts b/extensions/discord/src/actions/runtime.messaging.ts similarity index 71% rename from src/agents/tools/discord-actions-messaging.ts rename to extensions/discord/src/actions/runtime.messaging.ts index bad969ede80..92ef443cf44 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/extensions/discord/src/actions/runtime.messaging.ts @@ -1,7 +1,19 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; +import { withNormalizedTimestamp } from "../../../../src/agents/date-time.js"; +import { assertMediaNotDataUrl } from "../../../../src/agents/sandbox-paths.js"; +import { + type ActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { resolvePollMaxSelections } from "../../../../src/polls.js"; +import { readDiscordComponentSpec } from "../components.js"; import { createThreadDiscord, deleteMessageDiscord, @@ -23,20 +35,34 @@ import { sendStickerDiscord, sendVoiceMessageDiscord, unpinMessageDiscord, -} from "../../plugin-sdk/discord.js"; -import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js"; -import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js"; -import { resolvePollMaxSelections } from "../../polls.js"; -import { withNormalizedTimestamp } from "../date-time.js"; -import { assertMediaNotDataUrl } from "../sandbox-paths.js"; -import { - type ActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringArrayParam, - readStringParam, -} from "./common.js"; +} from "../send.js"; +import type { DiscordSendComponents, DiscordSendEmbeds } from "../send.shared.js"; +import { resolveDiscordChannelId } from "../targets.js"; + +export const discordMessagingActionRuntime = { + createThreadDiscord, + deleteMessageDiscord, + editMessageDiscord, + fetchChannelPermissionsDiscord, + fetchMessageDiscord, + fetchReactionsDiscord, + listPinsDiscord, + listThreadsDiscord, + pinMessageDiscord, + reactMessageDiscord, + readDiscordComponentSpec, + readMessagesDiscord, + removeOwnReactionsDiscord, + removeReactionDiscord, + resolveDiscordChannelId, + searchMessagesDiscord, + sendDiscordComponentMessage, + sendMessageDiscord, + sendPollDiscord, + sendStickerDiscord, + sendVoiceMessageDiscord, + unpinMessageDiscord, +}; function parseDiscordMessageLink(link: string) { const normalized = link.trim(); @@ -65,7 +91,7 @@ export async function handleDiscordMessagingAction( cfg?: OpenClawConfig, ): Promise> { const resolveChannelId = () => - resolveDiscordChannelId( + discordMessagingActionRuntime.resolveDiscordChannelId( readStringParam(params, "channelId", { required: true, }), @@ -95,28 +121,45 @@ export async function handleDiscordMessagingAction( }); if (remove) { if (accountId) { - await removeReactionDiscord(channelId, messageId, emoji, { + await discordMessagingActionRuntime.removeReactionDiscord(channelId, messageId, emoji, { ...cfgOptions, accountId, }); } else { - await removeReactionDiscord(channelId, messageId, emoji, cfgOptions); + await discordMessagingActionRuntime.removeReactionDiscord( + channelId, + messageId, + emoji, + cfgOptions, + ); } return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { const removed = accountId - ? await removeOwnReactionsDiscord(channelId, messageId, { ...cfgOptions, accountId }) - : await removeOwnReactionsDiscord(channelId, messageId, cfgOptions); + ? await discordMessagingActionRuntime.removeOwnReactionsDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.removeOwnReactionsDiscord( + channelId, + messageId, + cfgOptions, + ); return jsonResult({ ok: true, removed: removed.removed }); } if (accountId) { - await reactMessageDiscord(channelId, messageId, emoji, { + await discordMessagingActionRuntime.reactMessageDiscord(channelId, messageId, emoji, { ...cfgOptions, accountId, }); } else { - await reactMessageDiscord(channelId, messageId, emoji, cfgOptions); + await discordMessagingActionRuntime.reactMessageDiscord( + channelId, + messageId, + emoji, + cfgOptions, + ); } return jsonResult({ ok: true, added: emoji }); } @@ -129,11 +172,15 @@ export async function handleDiscordMessagingAction( required: true, }); const limit = readNumberParam(params, "limit"); - const reactions = await fetchReactionsDiscord(channelId, messageId, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), - limit, - }); + const reactions = await discordMessagingActionRuntime.fetchReactionsDiscord( + channelId, + messageId, + { + ...cfgOptions, + ...(accountId ? { accountId } : {}), + limit, + }, + ); return jsonResult({ ok: true, reactions }); } case "sticker": { @@ -146,7 +193,7 @@ export async function handleDiscordMessagingAction( required: true, label: "stickerIds", }); - await sendStickerDiscord(to, stickerIds, { + await discordMessagingActionRuntime.sendStickerDiscord(to, stickerIds, { ...cfgOptions, ...(accountId ? { accountId } : {}), content, @@ -169,7 +216,7 @@ export async function handleDiscordMessagingAction( const allowMultiselect = readBooleanParam(params, "allowMultiselect"); const durationHours = readNumberParam(params, "durationHours"); const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect); - await sendPollDiscord( + await discordMessagingActionRuntime.sendPollDiscord( to, { question, options: answers, maxSelections, durationHours }, { ...cfgOptions, ...(accountId ? { accountId } : {}), content }, @@ -182,8 +229,11 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const permissions = accountId - ? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId }) - : await fetchChannelPermissionsDiscord(channelId, cfgOptions); + ? await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, cfgOptions); return jsonResult({ ok: true, permissions }); } case "fetchMessage": { @@ -206,8 +256,11 @@ export async function handleDiscordMessagingAction( ); } const message = accountId - ? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }) - : await fetchMessageDiscord(channelId, messageId, cfgOptions); + ? await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, cfgOptions); return jsonResult({ ok: true, message: normalizeMessage(message), @@ -228,8 +281,11 @@ export async function handleDiscordMessagingAction( around: readStringParam(params, "around"), }; const messages = accountId - ? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId }) - : await readMessagesDiscord(channelId, query, cfgOptions); + ? await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, cfgOptions); return jsonResult({ ok: true, messages: messages.map((message) => normalizeMessage(message)), @@ -245,7 +301,7 @@ export async function handleDiscordMessagingAction( const rawComponents = params.components; const componentSpec = rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents) - ? readDiscordComponentSpec(rawComponents) + ? discordMessagingActionRuntime.readDiscordComponentSpec(rawComponents) : null; const components: DiscordSendComponents | undefined = Array.isArray(rawComponents) || typeof rawComponents === "function" @@ -279,16 +335,20 @@ export async function handleDiscordMessagingAction( const payload = componentSpec.text ? componentSpec : { ...componentSpec, text: normalizedContent }; - const result = await sendDiscordComponentMessage(to, payload, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), - silent, - replyTo: replyTo ?? undefined, - sessionKey: sessionKey ?? undefined, - agentId: agentId ?? undefined, - mediaUrl: mediaUrl ?? undefined, - filename: filename ?? undefined, - }); + const result = await discordMessagingActionRuntime.sendDiscordComponentMessage( + to, + payload, + { + ...cfgOptions, + ...(accountId ? { accountId } : {}), + silent, + replyTo: replyTo ?? undefined, + sessionKey: sessionKey ?? undefined, + agentId: agentId ?? undefined, + mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, + }, + ); return jsonResult({ ok: true, result, components: true }); } @@ -305,7 +365,7 @@ export async function handleDiscordMessagingAction( ); } assertMediaNotDataUrl(mediaUrl); - const result = await sendVoiceMessageDiscord(to, mediaUrl, { + const result = await discordMessagingActionRuntime.sendVoiceMessageDiscord(to, mediaUrl, { ...cfgOptions, ...(accountId ? { accountId } : {}), replyTo, @@ -314,7 +374,7 @@ export async function handleDiscordMessagingAction( return jsonResult({ ok: true, result, voiceMessage: true }); } - const result = await sendMessageDiscord(to, content ?? "", { + const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", { ...cfgOptions, ...(accountId ? { accountId } : {}), mediaUrl, @@ -338,8 +398,18 @@ export async function handleDiscordMessagingAction( required: true, }); const message = accountId - ? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId }) - : await editMessageDiscord(channelId, messageId, { content }, cfgOptions); + ? await discordMessagingActionRuntime.editMessageDiscord( + channelId, + messageId, + { content }, + { ...cfgOptions, accountId }, + ) + : await discordMessagingActionRuntime.editMessageDiscord( + channelId, + messageId, + { content }, + cfgOptions, + ); return jsonResult({ ok: true, message }); } case "deleteMessage": { @@ -351,9 +421,12 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); + await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }); } else { - await deleteMessageDiscord(channelId, messageId, cfgOptions); + await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -375,8 +448,11 @@ export async function handleDiscordMessagingAction( appliedTags: appliedTags ?? undefined, }; const thread = accountId - ? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId }) - : await createThreadDiscord(channelId, payload, cfgOptions); + ? await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, cfgOptions); return jsonResult({ ok: true, thread }); } case "threadList": { @@ -391,7 +467,7 @@ export async function handleDiscordMessagingAction( const before = readStringParam(params, "before"); const limit = readNumberParam(params, "limit"); const threads = accountId - ? await listThreadsDiscord( + ? await discordMessagingActionRuntime.listThreadsDiscord( { guildId, channelId, @@ -401,7 +477,7 @@ export async function handleDiscordMessagingAction( }, { ...cfgOptions, accountId }, ) - : await listThreadsDiscord( + : await discordMessagingActionRuntime.listThreadsDiscord( { guildId, channelId, @@ -423,13 +499,17 @@ export async function handleDiscordMessagingAction( }); const mediaUrl = readStringParam(params, "mediaUrl"); const replyTo = readStringParam(params, "replyTo"); - const result = await sendMessageDiscord(`channel:${channelId}`, content, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), - mediaUrl, - mediaLocalRoots: options?.mediaLocalRoots, - replyTo, - }); + const result = await discordMessagingActionRuntime.sendMessageDiscord( + `channel:${channelId}`, + content, + { + ...cfgOptions, + ...(accountId ? { accountId } : {}), + mediaUrl, + mediaLocalRoots: options?.mediaLocalRoots, + replyTo, + }, + ); return jsonResult({ ok: true, result }); } case "pinMessage": { @@ -441,9 +521,12 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); + await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }); } else { - await pinMessageDiscord(channelId, messageId, cfgOptions); + await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -456,9 +539,12 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); + await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }); } else { - await unpinMessageDiscord(channelId, messageId, cfgOptions); + await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -468,8 +554,11 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const pins = accountId - ? await listPinsDiscord(channelId, { ...cfgOptions, accountId }) - : await listPinsDiscord(channelId, cfgOptions); + ? await discordMessagingActionRuntime.listPinsDiscord(channelId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.listPinsDiscord(channelId, cfgOptions); return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) }); } case "searchMessages": { @@ -490,7 +579,7 @@ export async function handleDiscordMessagingAction( const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])]; const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])]; const results = accountId - ? await searchMessagesDiscord( + ? await discordMessagingActionRuntime.searchMessagesDiscord( { guildId, content, @@ -500,7 +589,7 @@ export async function handleDiscordMessagingAction( }, { ...cfgOptions, accountId }, ) - : await searchMessagesDiscord( + : await discordMessagingActionRuntime.searchMessagesDiscord( { guildId, content, diff --git a/src/agents/tools/discord-actions-moderation-shared.ts b/extensions/discord/src/actions/runtime.moderation-shared.ts similarity index 94% rename from src/agents/tools/discord-actions-moderation-shared.ts rename to extensions/discord/src/actions/runtime.moderation-shared.ts index b2d9ec0ba99..7b6ef95d8f1 100644 --- a/src/agents/tools/discord-actions-moderation-shared.ts +++ b/extensions/discord/src/actions/runtime.moderation-shared.ts @@ -1,5 +1,5 @@ import { PermissionFlagsBits } from "discord-api-types/v10"; -import { readNumberParam, readStringParam } from "./common.js"; +import { readNumberParam, readStringParam } from "../../../../src/agents/tools/common.js"; export type DiscordModerationAction = "timeout" | "kick" | "ban"; diff --git a/src/agents/tools/discord-actions-moderation.authz.test.ts b/extensions/discord/src/actions/runtime.moderation.authz.test.ts similarity index 81% rename from src/agents/tools/discord-actions-moderation.authz.test.ts rename to extensions/discord/src/actions/runtime.moderation.authz.test.ts index d6b3651ca88..66d2a4ba9d8 100644 --- a/src/agents/tools/discord-actions-moderation.authz.test.ts +++ b/extensions/discord/src/actions/runtime.moderation.authz.test.ts @@ -1,25 +1,30 @@ import { PermissionFlagsBits } from "discord-api-types/v10"; -import { describe, expect, it, vi } from "vitest"; -import type { DiscordActionConfig } from "../../config/config.js"; -import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { + discordModerationActionRuntime, + handleDiscordModerationAction, +} from "./runtime.moderation.js"; -const discordSendMocks = vi.hoisted(() => ({ - banMemberDiscord: vi.fn(async () => ({ ok: true })), - kickMemberDiscord: vi.fn(async () => ({ ok: true })), - timeoutMemberDiscord: vi.fn(async () => ({ id: "user-1" })), - hasAnyGuildPermissionDiscord: vi.fn(async () => false), -})); - -const { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord, hasAnyGuildPermissionDiscord } = - discordSendMocks; - -vi.mock("../../../extensions/discord/src/send.js", () => ({ - ...discordSendMocks, -})); +const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime }; +const banMemberDiscord = vi.fn(async () => ({ ok: true })); +const kickMemberDiscord = vi.fn(async () => ({ ok: true })); +const timeoutMemberDiscord = vi.fn(async () => ({ id: "user-1" })); +const hasAnyGuildPermissionDiscord = vi.fn(async () => false); const enableAllActions = (_key: keyof DiscordActionConfig, _defaultValue = true) => true; describe("discord moderation sender authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(discordModerationActionRuntime, originalDiscordModerationActionRuntime, { + banMemberDiscord, + kickMemberDiscord, + timeoutMemberDiscord, + hasAnyGuildPermissionDiscord, + }); + }); + it("rejects ban when sender lacks BAN_MEMBERS", async () => { hasAnyGuildPermissionDiscord.mockResolvedValueOnce(false); diff --git a/src/agents/tools/discord-actions-moderation.ts b/extensions/discord/src/actions/runtime.moderation.ts similarity index 78% rename from src/agents/tools/discord-actions-moderation.ts rename to extensions/discord/src/actions/runtime.moderation.ts index 56d7a80d4c9..3278daa6532 100644 --- a/src/agents/tools/discord-actions-moderation.ts +++ b/extensions/discord/src/actions/runtime.moderation.ts @@ -1,17 +1,28 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; +import { + type ActionGate, + jsonResult, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; import { banMemberDiscord, hasAnyGuildPermissionDiscord, kickMemberDiscord, timeoutMemberDiscord, -} from "../../plugin-sdk/discord.js"; -import { type ActionGate, jsonResult, readStringParam } from "./common.js"; +} from "../send.js"; import { isDiscordModerationAction, readDiscordModerationCommand, requiredGuildPermissionForModerationAction, -} from "./discord-actions-moderation-shared.js"; +} from "./runtime.moderation-shared.js"; + +export const discordModerationActionRuntime = { + banMemberDiscord, + hasAnyGuildPermissionDiscord, + kickMemberDiscord, + timeoutMemberDiscord, +}; async function verifySenderModerationPermission(params: { guildId: string; @@ -23,7 +34,7 @@ async function verifySenderModerationPermission(params: { if (!params.senderUserId) { return; } - const hasPermission = await hasAnyGuildPermissionDiscord( + const hasPermission = await discordModerationActionRuntime.hasAnyGuildPermissionDiscord( params.guildId, params.senderUserId, [params.requiredPermission], @@ -57,7 +68,7 @@ export async function handleDiscordModerationAction( switch (command.action) { case "timeout": { const member = accountId - ? await timeoutMemberDiscord( + ? await discordModerationActionRuntime.timeoutMemberDiscord( { guildId: command.guildId, userId: command.userId, @@ -67,7 +78,7 @@ export async function handleDiscordModerationAction( }, { accountId }, ) - : await timeoutMemberDiscord({ + : await discordModerationActionRuntime.timeoutMemberDiscord({ guildId: command.guildId, userId: command.userId, durationMinutes: command.durationMinutes, @@ -78,7 +89,7 @@ export async function handleDiscordModerationAction( } case "kick": { if (accountId) { - await kickMemberDiscord( + await discordModerationActionRuntime.kickMemberDiscord( { guildId: command.guildId, userId: command.userId, @@ -87,7 +98,7 @@ export async function handleDiscordModerationAction( { accountId }, ); } else { - await kickMemberDiscord({ + await discordModerationActionRuntime.kickMemberDiscord({ guildId: command.guildId, userId: command.userId, reason: command.reason, @@ -97,7 +108,7 @@ export async function handleDiscordModerationAction( } case "ban": { if (accountId) { - await banMemberDiscord( + await discordModerationActionRuntime.banMemberDiscord( { guildId: command.guildId, userId: command.userId, @@ -107,7 +118,7 @@ export async function handleDiscordModerationAction( { accountId }, ); } else { - await banMemberDiscord({ + await discordModerationActionRuntime.banMemberDiscord({ guildId: command.guildId, userId: command.userId, reason: command.reason, diff --git a/src/agents/tools/discord-actions-presence.test.ts b/extensions/discord/src/actions/runtime.presence.test.ts similarity index 94% rename from src/agents/tools/discord-actions-presence.test.ts rename to extensions/discord/src/actions/runtime.presence.test.ts index dc8080666c6..7cc118150de 100644 --- a/src/agents/tools/discord-actions-presence.test.ts +++ b/extensions/discord/src/actions/runtime.presence.test.ts @@ -1,12 +1,9 @@ import type { GatewayPlugin } from "@buape/carbon/gateway"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - clearGateways, - registerGateway, -} from "../../../extensions/discord/src/monitor/gateway-registry.js"; -import type { DiscordActionConfig } from "../../config/config.js"; -import type { ActionGate } from "./common.js"; -import { handleDiscordPresenceAction } from "./discord-actions-presence.js"; +import type { ActionGate } from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { clearGateways, registerGateway } from "../monitor/gateway-registry.js"; +import { handleDiscordPresenceAction } from "./runtime.presence.js"; const mockUpdatePresence = vi.fn(); diff --git a/src/agents/tools/discord-actions-presence.ts b/extensions/discord/src/actions/runtime.presence.ts similarity index 92% rename from src/agents/tools/discord-actions-presence.ts rename to extensions/discord/src/actions/runtime.presence.ts index 53c42829bb0..6d3a9f15bc2 100644 --- a/src/agents/tools/discord-actions-presence.ts +++ b/extensions/discord/src/actions/runtime.presence.ts @@ -1,8 +1,12 @@ import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; -import { getGateway } from "../../plugin-sdk/discord.js"; -import { type ActionGate, jsonResult, readStringParam } from "./common.js"; +import { + type ActionGate, + jsonResult, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { getGateway } from "../monitor/gateway-registry.js"; const ACTIVITY_TYPE_MAP: Record = { playing: 0, diff --git a/src/agents/tools/discord-actions-shared.ts b/extensions/discord/src/actions/runtime.shared.ts similarity index 78% rename from src/agents/tools/discord-actions-shared.ts rename to extensions/discord/src/actions/runtime.shared.ts index 6f8283b5240..bd2ce7a08d6 100644 --- a/src/agents/tools/discord-actions-shared.ts +++ b/extensions/discord/src/actions/runtime.shared.ts @@ -1,4 +1,4 @@ -import { readStringParam } from "./common.js"; +import { readStringParam } from "../../../../src/agents/tools/common.js"; export function readDiscordParentIdParam( params: Record, diff --git a/src/agents/tools/discord-actions.test.ts b/extensions/discord/src/actions/runtime.test.ts similarity index 94% rename from src/agents/tools/discord-actions.test.ts rename to extensions/discord/src/actions/runtime.test.ts index c03cb2fdafa..8f11162f8f3 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -1,11 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { DiscordActionConfig, OpenClawConfig } from "../../config/config.js"; -import { handleDiscordGuildAction } from "./discord-actions-guild.js"; -import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; -import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; -import { handleDiscordAction } from "./discord-actions.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js"; +import { handleDiscordAction } from "./runtime.js"; +import { + discordMessagingActionRuntime, + handleDiscordMessagingAction, +} from "./runtime.messaging.js"; +import { + discordModerationActionRuntime, + handleDiscordModerationAction, +} from "./runtime.moderation.js"; -const discordSendMocks = vi.hoisted(() => ({ +const originalDiscordMessagingActionRuntime = { ...discordMessagingActionRuntime }; +const originalDiscordGuildActionRuntime = { ...discordGuildActionRuntime }; +const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime }; + +const discordSendMocks = { banMemberDiscord: vi.fn(async () => ({})), createChannelDiscord: vi.fn(async () => ({ id: "new-channel", @@ -42,7 +53,7 @@ const discordSendMocks = vi.hoisted(() => ({ setChannelPermissionDiscord: vi.fn(async () => ({ ok: true })), timeoutMemberDiscord: vi.fn(async () => ({})), unpinMessageDiscord: vi.fn(async () => ({})), -})); +}; const { createChannelDiscord, @@ -67,21 +78,28 @@ const { timeoutMemberDiscord, } = discordSendMocks; -vi.mock("../../../extensions/discord/src/send.js", () => ({ - ...discordSendMocks, -})); - const enableAllActions = () => true; const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions"; const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo"; const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation"; -describe("handleDiscordMessagingAction", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +beforeEach(() => { + vi.clearAllMocks(); + Object.assign( + discordMessagingActionRuntime, + originalDiscordMessagingActionRuntime, + discordSendMocks, + ); + Object.assign(discordGuildActionRuntime, originalDiscordGuildActionRuntime, discordSendMocks); + Object.assign( + discordModerationActionRuntime, + originalDiscordModerationActionRuntime, + discordSendMocks, + ); +}); +describe("handleDiscordMessagingAction", () => { it.each([ { name: "without account", diff --git a/src/agents/tools/discord-actions.ts b/extensions/discord/src/actions/runtime.ts similarity index 79% rename from src/agents/tools/discord-actions.ts rename to extensions/discord/src/actions/runtime.ts index b953e56cffd..7efa5a1536f 100644 --- a/src/agents/tools/discord-actions.ts +++ b/extensions/discord/src/actions/runtime.ts @@ -1,11 +1,11 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createDiscordActionGate } from "../../plugin-sdk/discord.js"; -import { readStringParam } from "./common.js"; -import { handleDiscordGuildAction } from "./discord-actions-guild.js"; -import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; -import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; -import { handleDiscordPresenceAction } from "./discord-actions-presence.js"; +import { readStringParam } from "../../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { createDiscordActionGate } from "../accounts.js"; +import { handleDiscordGuildAction } from "./runtime.guild.js"; +import { handleDiscordMessagingAction } from "./runtime.messaging.js"; +import { handleDiscordModerationAction } from "./runtime.moderation.js"; +import { handleDiscordPresenceAction } from "./runtime.presence.js"; const messagingActions = new Set([ "react", diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index c4be7728439..960b08acdf6 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -1,4 +1,5 @@ import { + createLegacyMessageToolDiscoveryMethods, createDiscordMessageToolComponentsSchema, createUnionActionGate, listTokenSourcedAccounts, @@ -132,6 +133,7 @@ function describeDiscordMessageTool({ export const discordMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeDiscordMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeDiscordMessageTool), extractToolSend: ({ args }) => { const action = typeof args.action === "string" ? args.action.trim() : ""; if (action === "sendMessage") { diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 03490dc8432..5aaff75014f 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -16,14 +16,18 @@ export * from "../agents/provider-id.js"; export * from "../agents/schema/typebox.js"; export * from "../agents/sglang-defaults.js"; export * from "../agents/tools/common.js"; -export * from "../agents/tools/discord-actions-shared.js"; -export * from "../agents/tools/discord-actions.js"; -export * from "../agents/tools/telegram-actions.js"; export * from "../agents/tools/web-guarded-fetch.js"; export * from "../agents/tools/web-shared.js"; -export * from "../agents/tools/discord-actions-moderation-shared.js"; export * from "../agents/tools/web-fetch-utils.js"; export * from "../agents/vllm-defaults.js"; // Intentional public runtime surface: channel plugins use ingress agent helpers directly. export * from "../agents/agent-command.js"; export * from "../tts/tts.js"; +// Legacy channel action runtime re-exports. New bundled plugin code should use +// local extension-owned modules instead of adding more public SDK surface here. +export { + handleDiscordAction, + readDiscordParentIdParam, + isDiscordModerationAction, + readDiscordModerationCommand, +} from "../../extensions/discord/runtime-api.js"; From c3386d34d2900f4628e4e1e0721e43a08aab74a6 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:07:13 +0000 Subject: [PATCH 14/31] Telegram: move action runtime into extension --- extensions/telegram/runtime-api.ts | 1 + .../telegram/src/action-runtime.test.ts | 39 +++--- .../telegram/src/action-runtime.ts | 118 +++++++++++------- extensions/telegram/src/channel-actions.ts | 4 +- src/plugin-sdk/agent-runtime.ts | 4 + 5 files changed, 96 insertions(+), 70 deletions(-) rename src/agents/tools/telegram-actions.test.ts => extensions/telegram/src/action-runtime.test.ts (95%) rename src/agents/tools/telegram-actions.ts => extensions/telegram/src/action-runtime.ts (87%) diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts index e704dc007a3..76f87396469 100644 --- a/extensions/telegram/runtime-api.ts +++ b/extensions/telegram/runtime-api.ts @@ -1,4 +1,5 @@ export * from "./src/audit.js"; +export * from "./src/action-runtime.js"; export * from "./src/channel-actions.js"; export * from "./src/monitor.js"; export * from "./src/probe.js"; diff --git a/src/agents/tools/telegram-actions.test.ts b/extensions/telegram/src/action-runtime.test.ts similarity index 95% rename from src/agents/tools/telegram-actions.test.ts rename to extensions/telegram/src/action-runtime.test.ts index 997de707765..ad59933415f 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -1,8 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { captureEnv } from "../../test-utils/env.js"; -import { handleTelegramAction, readTelegramButtons } from "./telegram-actions.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { captureEnv } from "../../../test/helpers/extensions/env.js"; +import { + handleTelegramAction, + readTelegramButtons, + telegramActionRuntime, +} from "./action-runtime.js"; +const originalTelegramActionRuntime = { ...telegramActionRuntime }; const reactMessageTelegram = vi.fn(async () => ({ ok: true })); const sendMessageTelegram = vi.fn(async () => ({ messageId: "789", @@ -36,24 +41,6 @@ const createForumTopicTelegram = vi.fn(async () => ({ })); let envSnapshot: ReturnType; -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - reactMessageTelegram: (...args: Parameters) => - reactMessageTelegram(...args), - sendMessageTelegram: (...args: Parameters) => - sendMessageTelegram(...args), - sendPollTelegram: (...args: Parameters) => sendPollTelegram(...args), - sendStickerTelegram: (...args: Parameters) => - sendStickerTelegram(...args), - deleteMessageTelegram: (...args: Parameters) => - deleteMessageTelegram(...args), - editMessageTelegram: (...args: Parameters) => - editMessageTelegram(...args), - editForumTopicTelegram: (...args: Parameters) => - editForumTopicTelegram(...args), - createForumTopicTelegram: (...args: Parameters) => - createForumTopicTelegram(...args), -})); - describe("handleTelegramAction", () => { const defaultReactionAction = { action: "react", @@ -107,6 +94,16 @@ describe("handleTelegramAction", () => { beforeEach(() => { envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]); + Object.assign(telegramActionRuntime, originalTelegramActionRuntime, { + reactMessageTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, + deleteMessageTelegram, + editMessageTelegram, + editForumTopicTelegram, + createForumTopicTelegram, + }); reactMessageTelegram.mockClear(); sendMessageTelegram.mockClear(); sendPollTelegram.mockClear(); diff --git a/src/agents/tools/telegram-actions.ts b/extensions/telegram/src/action-runtime.ts similarity index 87% rename from src/agents/tools/telegram-actions.ts rename to extensions/telegram/src/action-runtime.ts index d648b1e5f41..e6e56e9eb3a 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -1,15 +1,23 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { - createTelegramActionGate, - resolveTelegramPollActionGateState, -} from "../../plugin-sdk/telegram.js"; -import type { TelegramButtonStyle, TelegramInlineButtons } from "../../plugin-sdk/telegram.js"; + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringOrNumberParam, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; +import { resolvePollMaxSelections } from "../../../src/polls.js"; +import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js"; +import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, -} from "../../plugin-sdk/telegram.js"; +} from "./inline-buttons.js"; +import { resolveTelegramReactionLevel } from "./reaction-level.js"; import { createForumTopicTelegram, deleteMessageTelegram, @@ -19,22 +27,22 @@ import { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../plugin-sdk/telegram.js"; -import { +} from "./send.js"; +import { getCacheStats, searchStickers } from "./sticker-cache.js"; +import { resolveTelegramToken } from "./token.js"; + +export const telegramActionRuntime = { + createForumTopicTelegram, + deleteMessageTelegram, + editForumTopicTelegram, + editMessageTelegram, getCacheStats, - resolveTelegramReactionLevel, - resolveTelegramToken, + reactMessageTelegram, searchStickers, -} from "../../plugin-sdk/telegram.js"; -import { resolvePollMaxSelections } from "../../polls.js"; -import { - jsonResult, - readNumberParam, - readReactionParams, - readStringArrayParam, - readStringOrNumberParam, - readStringParam, -} from "./common.js"; + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, +}; const TELEGRAM_BUTTON_STYLES: readonly TelegramButtonStyle[] = ["danger", "success", "primary"]; @@ -155,14 +163,19 @@ export async function handleTelegramAction( hint: "Telegram bot token missing. Do not retry.", }); } - let reactionResult: Awaited>; + let reactionResult: Awaited>; try { - reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { - cfg, - token, - remove, - accountId: accountId ?? undefined, - }); + reactionResult = await telegramActionRuntime.reactMessageTelegram( + chatId ?? "", + messageId ?? 0, + emoji ?? "", + { + cfg, + token, + remove, + accountId: accountId ?? undefined, + }, + ); } catch (err) { const isInvalid = String(err).includes("REACTION_INVALID"); return jsonResult({ @@ -241,7 +254,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await sendMessageTelegram(to, content, { + const result = await telegramActionRuntime.sendMessageTelegram(to, content, { cfg, token, accountId: accountId ?? undefined, @@ -290,7 +303,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await sendPollTelegram( + const result = await telegramActionRuntime.sendPollTelegram( to, { question, @@ -334,7 +347,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - await deleteMessageTelegram(chatId ?? "", messageId ?? 0, { + await telegramActionRuntime.deleteMessageTelegram(chatId ?? "", messageId ?? 0, { cfg, token, accountId: accountId ?? undefined, @@ -375,12 +388,17 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, { - cfg, - token, - accountId: accountId ?? undefined, - buttons, - }); + const result = await telegramActionRuntime.editMessageTelegram( + chatId ?? "", + messageId ?? 0, + content, + { + cfg, + token, + accountId: accountId ?? undefined, + buttons, + }, + ); return jsonResult({ ok: true, messageId: result.messageId, @@ -408,7 +426,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await sendStickerTelegram(to, fileId, { + const result = await telegramActionRuntime.sendStickerTelegram(to, fileId, { cfg, token, accountId: accountId ?? undefined, @@ -430,7 +448,7 @@ export async function handleTelegramAction( } const query = readStringParam(params, "query", { required: true }); const limit = readNumberParam(params, "limit", { integer: true }) ?? 5; - const results = searchStickers(query, limit); + const results = telegramActionRuntime.searchStickers(query, limit); return jsonResult({ ok: true, count: results.length, @@ -444,7 +462,7 @@ export async function handleTelegramAction( } if (action === "stickerCacheStats") { - const stats = getCacheStats(); + const stats = telegramActionRuntime.getCacheStats(); return jsonResult({ ok: true, ...stats }); } @@ -464,7 +482,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await createForumTopicTelegram(chatId ?? "", name, { + const result = await telegramActionRuntime.createForumTopicTelegram(chatId ?? "", name, { cfg, token, accountId: accountId ?? undefined, @@ -500,13 +518,17 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await editForumTopicTelegram(chatId ?? "", messageThreadId, { - cfg, - token, - accountId: accountId ?? undefined, - name: name ?? undefined, - iconCustomEmojiId: iconCustomEmojiId ?? undefined, - }); + const result = await telegramActionRuntime.editForumTopicTelegram( + chatId ?? "", + messageThreadId, + { + cfg, + token, + accountId: accountId ?? undefined, + name: name ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + }, + ); return jsonResult(result); } diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index a23430f02da..cd757688835 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -4,10 +4,10 @@ import { readStringOrNumberParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; -import { handleTelegramAction } from "openclaw/plugin-sdk/agent-runtime"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import { + createLegacyMessageToolDiscoveryMethods, createMessageToolButtonsSchema, createTelegramPollExtraToolSchemas, createUnionActionGate, @@ -27,6 +27,7 @@ import { listEnabledTelegramAccounts, resolveTelegramPollActionGateState, } from "./accounts.js"; +import { handleTelegramAction } from "./action-runtime.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js"; @@ -177,6 +178,7 @@ function readTelegramMessageIdParam(params: Record): number { export const telegramMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeTelegramMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeTelegramMessageTool), extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 5aaff75014f..20ab0596a12 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -31,3 +31,7 @@ export { isDiscordModerationAction, readDiscordModerationCommand, } from "../../extensions/discord/runtime-api.js"; +export { + handleTelegramAction, + readTelegramButtons, +} from "../../extensions/telegram/runtime-api.js"; From b3ae50c71cb80b49ae30c589c8f385846e9683d8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:07:26 +0000 Subject: [PATCH 15/31] Slack: move action runtime into extension --- extensions/slack/runtime-api.ts | 1 + .../slack/src/action-runtime.test.ts | 352 ++++++++++-------- .../slack/src/action-runtime.ts | 110 +++--- src/channels/plugins/slack.actions.ts | 7 +- src/plugin-sdk/slack.ts | 2 +- .../runtime/runtime-slack-ops.runtime.ts | 2 +- src/plugins/runtime/types-channel.ts | 2 +- 7 files changed, 264 insertions(+), 212 deletions(-) rename src/agents/tools/slack-actions.test.ts => extensions/slack/src/action-runtime.test.ts (64%) rename src/agents/tools/slack-actions.ts => extensions/slack/src/action-runtime.ts (79%) diff --git a/extensions/slack/runtime-api.ts b/extensions/slack/runtime-api.ts index b40f24e4177..68281fd83d3 100644 --- a/extensions/slack/runtime-api.ts +++ b/extensions/slack/runtime-api.ts @@ -1,3 +1,4 @@ +export * from "./src/action-runtime.js"; export * from "./src/directory-live.js"; export * from "./src/index.js"; export * from "./src/resolve-channels.js"; diff --git a/src/agents/tools/slack-actions.test.ts b/extensions/slack/src/action-runtime.test.ts similarity index 64% rename from src/agents/tools/slack-actions.test.ts rename to extensions/slack/src/action-runtime.test.ts index bf28c2bed01..803118b877a 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { handleSlackAction } from "./slack-actions.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { handleSlackAction, slackActionRuntime } from "./action-runtime.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +const originalSlackActionRuntime = { ...slackActionRuntime }; const deleteSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const downloadSlackFile = vi.fn(async (..._args: unknown[]) => null); const editSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); @@ -14,31 +16,10 @@ const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({})); const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]); const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({})); -const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); +const recordSlackThreadParticipation = vi.fn(); +const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" })); const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); -vi.mock("../../../extensions/slack/src/actions.js", () => ({ - deleteSlackMessage: (...args: Parameters) => - deleteSlackMessage(...args), - downloadSlackFile: (...args: Parameters) => downloadSlackFile(...args), - editSlackMessage: (...args: Parameters) => editSlackMessage(...args), - getSlackMemberInfo: (...args: Parameters) => - getSlackMemberInfo(...args), - listSlackEmojis: (...args: Parameters) => listSlackEmojis(...args), - listSlackPins: (...args: Parameters) => listSlackPins(...args), - listSlackReactions: (...args: Parameters) => - listSlackReactions(...args), - pinSlackMessage: (...args: Parameters) => pinSlackMessage(...args), - reactSlackMessage: (...args: Parameters) => reactSlackMessage(...args), - readSlackMessages: (...args: Parameters) => readSlackMessages(...args), - removeOwnSlackReactions: (...args: Parameters) => - removeOwnSlackReactions(...args), - removeSlackReaction: (...args: Parameters) => - removeSlackReaction(...args), - sendSlackMessage: (...args: Parameters) => sendSlackMessage(...args), - unpinSlackMessage: (...args: Parameters) => unpinSlackMessage(...args), -})); - describe("handleSlackAction", () => { function slackConfig(overrides?: Record): OpenClawConfig { return { @@ -105,6 +86,24 @@ describe("handleSlackAction", () => { beforeEach(() => { vi.clearAllMocks(); + Object.assign(slackActionRuntime, originalSlackActionRuntime, { + deleteSlackMessage, + downloadSlackFile, + editSlackMessage, + getSlackMemberInfo, + listSlackEmojis, + listSlackPins, + listSlackReactions, + parseSlackBlocksInput, + pinSlackMessage, + reactSlackMessage, + readSlackMessages, + recordSlackThreadParticipation, + removeOwnSlackReactions, + removeSlackReaction, + sendSlackMessage, + unpinSlackMessage, + }); }); it.each([ @@ -261,6 +260,7 @@ describe("handleSlackAction", () => { { action: "sendMessage", to: "channel:C123", + content: "", blocks, }, slackConfig(), @@ -275,7 +275,7 @@ describe("handleSlackAction", () => { it.each([ { name: "invalid blocks JSON", - blocks: "{bad-json", + blocks: "{not json", expectedError: /blocks must be valid JSON/i, }, { name: "empty blocks arrays", blocks: "[]", expectedError: /at least one block/i }, @@ -285,6 +285,7 @@ describe("handleSlackAction", () => { { action: "sendMessage", to: "channel:C123", + content: "", blocks, }, slackConfig(), @@ -311,8 +312,9 @@ describe("handleSlackAction", () => { { action: "sendMessage", to: "channel:C123", - blocks: [{ type: "divider" }], - mediaUrl: "https://example.com/image.png", + content: "hello", + mediaUrl: "https://example.com/file.png", + blocks: JSON.stringify([{ type: "divider" }]), }, slackConfig(), ), @@ -322,13 +324,13 @@ describe("handleSlackAction", () => { it.each([ { name: "JSON blocks", - blocks: JSON.stringify([{ type: "section", text: { type: "mrkdwn", text: "Updated" } }]), - expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "Updated" } }], + blocks: JSON.stringify([{ type: "divider" }]), + expectedBlocks: [{ type: "divider" }], }, { name: "array blocks", - blocks: [{ type: "divider" }], - expectedBlocks: [{ type: "divider" }], + blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], + expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], }, ])("passes $name to editSlackMessage", async ({ blocks, expectedBlocks }) => { await handleSlackAction( @@ -336,6 +338,7 @@ describe("handleSlackAction", () => { action: "editMessage", channelId: "C123", messageId: "123.456", + content: "", blocks, }, slackConfig(), @@ -360,40 +363,32 @@ describe("handleSlackAction", () => { }); it("auto-injects threadTs from context when replyToMode=all", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( { action: "sendMessage", to: "channel:C123", - content: "Auto-threaded", + content: "Threaded reply", }, - cfg, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Auto-threaded", { - mediaUrl: undefined, - threadTs: "1111111111.111111", - blocks: undefined, - }); + expectLastSlackSend("Threaded reply", "1111111111.111111"); }); it("replyToMode=first threads first message then stops", async () => { - const { cfg, context, hasRepliedRef } = createReplyToFirstScenario(); + const { cfg, context } = createReplyToFirstScenario(); - // First message should be threaded await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "First" }, cfg, context, ); - expectLastSlackSend("First", "1111111111.111111"); - expect(hasRepliedRef.value).toBe(true); + expectLastSlackSend("First", "1111111111.111111"); await sendSecondMessageAndExpectNoThread({ cfg, context }); }); @@ -405,73 +400,54 @@ describe("handleSlackAction", () => { action: "sendMessage", to: "channel:C123", content: "Explicit", - threadTs: "2222222222.222222", + threadTs: "9999999999.999999", }, cfg, context, ); - expectLastSlackSend("Explicit", "2222222222.222222"); - expect(hasRepliedRef.value).toBe(true); + expectLastSlackSend("Explicit", "9999999999.999999"); + expect(hasRepliedRef.value).toBe(true); await sendSecondMessageAndExpectNoThread({ cfg, context }); }); it("replyToMode=first without hasRepliedRef does not thread", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); - await handleSlackAction({ action: "sendMessage", to: "channel:C123", content: "No ref" }, cfg, { - currentChannelId: "C123", - currentThreadTs: "1111111111.111111", - replyToMode: "first", - // no hasRepliedRef - }); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "No ref", { - mediaUrl: undefined, - threadTs: undefined, - blocks: undefined, - }); + await handleSlackAction( + { action: "sendMessage", to: "channel:C123", content: "No ref" }, + slackConfig(), + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "first", + }, + ); + expectLastSlackSend("No ref"); }); it("does not auto-inject threadTs when replyToMode=off", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( - { - action: "sendMessage", - to: "channel:C123", - content: "Off mode", - }, - cfg, + { action: "sendMessage", to: "channel:C123", content: "No thread" }, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "off", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Off mode", { - mediaUrl: undefined, - threadTs: undefined, - blocks: undefined, - }); + expectLastSlackSend("No thread"); }); it("does not auto-inject threadTs when sending to different channel", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( - { - action: "sendMessage", - to: "channel:C999", - content: "Different channel", - }, - cfg, + { action: "sendMessage", to: "channel:C999", content: "Other channel" }, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Different channel", { + expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Other channel", { mediaUrl: undefined, threadTs: undefined, blocks: undefined, @@ -479,46 +455,34 @@ describe("handleSlackAction", () => { }); it("explicit threadTs overrides context threadTs", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( { action: "sendMessage", to: "channel:C123", - content: "Explicit thread", - threadTs: "2222222222.222222", + content: "Explicit wins", + threadTs: "9999999999.999999", }, - cfg, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Explicit thread", { - mediaUrl: undefined, - threadTs: "2222222222.222222", - blocks: undefined, - }); + expectLastSlackSend("Explicit wins", "9999999999.999999"); }); it("handles channel target without prefix when replyToMode=all", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( - { - action: "sendMessage", - to: "C123", - content: "No prefix", - }, - cfg, + { action: "sendMessage", to: "C123", content: "Bare target" }, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("C123", "No prefix", { + expect(sendSlackMessage).toHaveBeenCalledWith("C123", "Bare target", { mediaUrl: undefined, threadTs: "1111111111.111111", blocks: undefined, @@ -526,104 +490,164 @@ describe("handleSlackAction", () => { }); it("adds normalized timestamps to readMessages payloads", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; readSlackMessages.mockResolvedValueOnce({ - messages: [{ ts: "1735689600.456", text: "hi" }], + messages: [{ ts: "1712345678.123456", text: "hi" }], hasMore: false, }); - const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg); - const payload = result.details as { - messages: Array<{ timestampMs?: number; timestampUtc?: string }>; - }; + const result = await handleSlackAction( + { action: "readMessages", channelId: "C1" }, + slackConfig(), + ); - const expectedMs = Math.round(1735689600.456 * 1000); - expect(payload.messages[0].timestampMs).toBe(expectedMs); - expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); + expect(result).toMatchObject({ + details: { + ok: true, + hasMore: false, + messages: [ + expect.objectContaining({ + ts: "1712345678.123456", + timestampMs: 1712345678123, + }), + ], + }, + }); }); it("passes threadId through to readSlackMessages", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - readSlackMessages.mockClear(); readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); await handleSlackAction( - { action: "readMessages", channelId: "C1", threadId: "12345.6789" }, - cfg, + { action: "readMessages", channelId: "C1", threadId: "1712345678.123456" }, + slackConfig(), ); - const opts = readSlackMessages.mock.calls[0]?.[1] as { threadId?: string } | undefined; - expect(opts?.threadId).toBe("12345.6789"); + expect(readSlackMessages).toHaveBeenCalledWith("C1", { + threadId: "1712345678.123456", + limit: undefined, + before: undefined, + after: undefined, + }); }); it("adds normalized timestamps to pin payloads", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - listSlackPins.mockResolvedValueOnce([ - { - type: "message", - message: { ts: "1735689600.789", text: "pinned" }, + listSlackPins.mockResolvedValueOnce([{ message: { ts: "1712345678.123456", text: "pin" } }]); + + const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, slackConfig()); + + expect(result).toMatchObject({ + details: { + ok: true, + pins: [ + { + message: expect.objectContaining({ + ts: "1712345678.123456", + timestampMs: 1712345678123, + }), + }, + ], }, - ]); - - const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg); - const payload = result.details as { - pins: Array<{ message?: { timestampMs?: number; timestampUtc?: string } }>; - }; - - const expectedMs = Math.round(1735689600.789 * 1000); - expect(payload.pins[0].message?.timestampMs).toBe(expectedMs); - expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString()); + }); }); it("uses user token for reads when available", async () => { - const cfg = { - channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } }, - } as OpenClawConfig; - expect(await resolveReadToken(cfg)).toBe("xoxp-1"); + const token = await resolveReadToken( + slackConfig({ + accounts: { + default: { + botToken: "xoxb-bot", + userToken: "xoxp-user", + }, + }, + }), + ); + expect(token).toBe("xoxp-user"); }); it("falls back to bot token for reads when user token missing", async () => { - const cfg = { - channels: { slack: { botToken: "xoxb-1" } }, - } as OpenClawConfig; - expect(await resolveReadToken(cfg)).toBeUndefined(); + const token = await resolveReadToken( + slackConfig({ + accounts: { + default: { + botToken: "xoxb-bot", + }, + }, + }), + ); + expect(token).toBeUndefined(); }); it("uses bot token for writes when userTokenReadOnly is true", async () => { - const cfg = { - channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } }, - } as OpenClawConfig; - expect(await resolveSendToken(cfg)).toBeUndefined(); + const token = await resolveSendToken( + slackConfig({ + accounts: { + default: { + botToken: "xoxb-bot", + userToken: "xoxp-user", + userTokenReadOnly: true, + }, + }, + }), + ); + expect(token).toBeUndefined(); }); it("allows user token writes when bot token is missing", async () => { - const cfg = { + const token = await resolveSendToken({ channels: { - slack: { userToken: "xoxp-1", userTokenReadOnly: false }, + slack: { + accounts: { + default: { + userToken: "xoxp-user", + userTokenReadOnly: false, + }, + }, + }, }, - } as OpenClawConfig; - expect(await resolveSendToken(cfg)).toBe("xoxp-1"); + } as OpenClawConfig); + expect(token).toBe("xoxp-user"); }); it("returns all emojis when no limit is provided", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const emojiMap = { wave: "url1", smile: "url2", heart: "url3" }; - listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: emojiMap }); - const result = await handleSlackAction({ action: "emojiList" }, cfg); - const payload = result.details as { ok: boolean; emojis: { emoji: Record } }; - expect(payload.ok).toBe(true); - expect(Object.keys(payload.emojis.emoji)).toHaveLength(3); + listSlackEmojis.mockResolvedValueOnce({ + ok: true, + emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, + }); + + const result = await handleSlackAction({ action: "emojiList" }, slackConfig()); + + expect(result).toMatchObject({ + details: { + ok: true, + emojis: { + emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, + }, + }, + }); }); it("applies limit to emoji-list results", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const emojiMap = { wave: "url1", smile: "url2", heart: "url3", fire: "url4", star: "url5" }; - listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: emojiMap }); - const result = await handleSlackAction({ action: "emojiList", limit: 2 }, cfg); - const payload = result.details as { ok: boolean; emojis: { emoji: Record } }; - expect(payload.ok).toBe(true); - const emojiKeys = Object.keys(payload.emojis.emoji); - expect(emojiKeys).toHaveLength(2); - expect(emojiKeys.every((k) => k in emojiMap)).toBe(true); + listSlackEmojis.mockResolvedValueOnce({ + ok: true, + emoji: { + wave: "https://example.com/wave.png", + party: "https://example.com/party.png", + tada: "https://example.com/tada.png", + }, + }); + + const result = await handleSlackAction({ action: "emojiList", limit: 2 }, slackConfig()); + + expect(result).toMatchObject({ + details: { + ok: true, + emojis: { + emoji: { + party: "https://example.com/party.png", + tada: "https://example.com/tada.png", + }, + }, + }, + }); }); }); diff --git a/src/agents/tools/slack-actions.ts b/extensions/slack/src/action-runtime.ts similarity index 79% rename from src/agents/tools/slack-actions.ts rename to extensions/slack/src/action-runtime.ts index 11283394ec8..deb5eb0218e 100644 --- a/src/agents/tools/slack-actions.ts +++ b/extensions/slack/src/action-runtime.ts @@ -1,6 +1,15 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { resolveSlackAccount } from "../../plugin-sdk/account-resolution.js"; +import { withNormalizedTimestamp } from "../../../src/agents/date-time.js"; +import { + createActionGate, + imageResultFromFile, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveSlackAccount } from "./accounts.js"; import { deleteSlackMessage, downloadSlackFile, @@ -16,22 +25,10 @@ import { removeSlackReaction, sendSlackMessage, unpinSlackMessage, -} from "../../plugin-sdk/slack.js"; -import { - parseSlackBlocksInput, - parseSlackTarget, - recordSlackThreadParticipation, - resolveSlackChannelId, -} from "../../plugin-sdk/slack.js"; -import { withNormalizedTimestamp } from "../date-time.js"; -import { - createActionGate, - imageResultFromFile, - jsonResult, - readNumberParam, - readReactionParams, - readStringParam, -} from "./common.js"; +} from "./actions.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +import { recordSlackThreadParticipation } from "./sent-thread-cache.js"; +import { parseSlackTarget, resolveSlackChannelId } from "./targets.js"; const messagingActions = new Set([ "sendMessage", @@ -44,6 +41,25 @@ const messagingActions = new Set([ const reactionsActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +export const slackActionRuntime = { + deleteSlackMessage, + downloadSlackFile, + editSlackMessage, + getSlackMemberInfo, + listSlackEmojis, + listSlackPins, + listSlackReactions, + parseSlackBlocksInput, + pinSlackMessage, + reactSlackMessage, + readSlackMessages, + recordSlackThreadParticipation, + removeOwnSlackReactions, + removeSlackReaction, + sendSlackMessage, + unpinSlackMessage, +}; + export type SlackActionContext = { /** Current channel ID for auto-threading. */ currentChannelId?: string; @@ -102,7 +118,7 @@ function resolveThreadTsFromContext( } function readSlackBlocksParam(params: Record) { - return parseSlackBlocksInput(params.blocks); + return slackActionRuntime.parseSlackBlocksInput(params.blocks); } export async function handleSlackAction( @@ -163,28 +179,28 @@ export async function handleSlackAction( }); if (remove) { if (writeOpts) { - await removeSlackReaction(channelId, messageId, emoji, writeOpts); + await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts); } else { - await removeSlackReaction(channelId, messageId, emoji); + await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji); } return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { const removed = writeOpts - ? await removeOwnSlackReactions(channelId, messageId, writeOpts) - : await removeOwnSlackReactions(channelId, messageId); + ? await slackActionRuntime.removeOwnSlackReactions(channelId, messageId, writeOpts) + : await slackActionRuntime.removeOwnSlackReactions(channelId, messageId); return jsonResult({ ok: true, removed }); } if (writeOpts) { - await reactSlackMessage(channelId, messageId, emoji, writeOpts); + await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts); } else { - await reactSlackMessage(channelId, messageId, emoji); + await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji); } return jsonResult({ ok: true, added: emoji }); } const reactions = readOpts - ? await listSlackReactions(channelId, messageId, readOpts) - : await listSlackReactions(channelId, messageId); + ? await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts) + : await slackActionRuntime.listSlackReactions(channelId, messageId); return jsonResult({ ok: true, reactions }); } @@ -211,7 +227,7 @@ export async function handleSlackAction( to, context, ); - const result = await sendSlackMessage(to, content ?? "", { + const result = await slackActionRuntime.sendSlackMessage(to, content ?? "", { ...writeOpts, mediaUrl: mediaUrl ?? undefined, mediaLocalRoots: context?.mediaLocalRoots, @@ -220,7 +236,11 @@ export async function handleSlackAction( }); if (threadTs && result.channelId && account.accountId) { - recordSlackThreadParticipation(account.accountId, result.channelId, threadTs); + slackActionRuntime.recordSlackThreadParticipation( + account.accountId, + result.channelId, + threadTs, + ); } // Keep "first" mode consistent even when the agent explicitly provided @@ -248,12 +268,12 @@ export async function handleSlackAction( throw new Error("Slack editMessage requires content or blocks."); } if (writeOpts) { - await editSlackMessage(channelId, messageId, content ?? "", { + await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", { ...writeOpts, blocks, }); } else { - await editSlackMessage(channelId, messageId, content ?? "", { + await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", { blocks, }); } @@ -265,9 +285,9 @@ export async function handleSlackAction( required: true, }); if (writeOpts) { - await deleteSlackMessage(channelId, messageId, writeOpts); + await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts); } else { - await deleteSlackMessage(channelId, messageId); + await slackActionRuntime.deleteSlackMessage(channelId, messageId); } return jsonResult({ ok: true }); } @@ -279,7 +299,7 @@ export async function handleSlackAction( const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); const threadId = readStringParam(params, "threadId"); - const result = await readSlackMessages(channelId, { + const result = await slackActionRuntime.readSlackMessages(channelId, { ...readOpts, limit, before: before ?? undefined, @@ -302,7 +322,7 @@ export async function handleSlackAction( const maxBytes = account.config?.mediaMaxMb ? account.config.mediaMaxMb * 1024 * 1024 : 20 * 1024 * 1024; - const downloaded = await downloadSlackFile(fileId, { + const downloaded = await slackActionRuntime.downloadSlackFile(fileId, { ...readOpts, maxBytes, channelId, @@ -336,9 +356,9 @@ export async function handleSlackAction( required: true, }); if (writeOpts) { - await pinSlackMessage(channelId, messageId, writeOpts); + await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts); } else { - await pinSlackMessage(channelId, messageId); + await slackActionRuntime.pinSlackMessage(channelId, messageId); } return jsonResult({ ok: true }); } @@ -347,15 +367,15 @@ export async function handleSlackAction( required: true, }); if (writeOpts) { - await unpinSlackMessage(channelId, messageId, writeOpts); + await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts); } else { - await unpinSlackMessage(channelId, messageId); + await slackActionRuntime.unpinSlackMessage(channelId, messageId); } return jsonResult({ ok: true }); } const pins = writeOpts - ? await listSlackPins(channelId, readOpts) - : await listSlackPins(channelId); + ? await slackActionRuntime.listSlackPins(channelId, readOpts) + : await slackActionRuntime.listSlackPins(channelId); const normalizedPins = pins.map((pin) => { const message = pin.message ? withNormalizedTimestamp( @@ -374,8 +394,8 @@ export async function handleSlackAction( } const userId = readStringParam(params, "userId", { required: true }); const info = writeOpts - ? await getSlackMemberInfo(userId, readOpts) - : await getSlackMemberInfo(userId); + ? await slackActionRuntime.getSlackMemberInfo(userId, readOpts) + : await slackActionRuntime.getSlackMemberInfo(userId); return jsonResult({ ok: true, info }); } @@ -383,7 +403,9 @@ export async function handleSlackAction( if (!isActionEnabled("emojiList")) { throw new Error("Slack emoji list is disabled."); } - const result = readOpts ? await listSlackEmojis(readOpts) : await listSlackEmojis(); + const result = readOpts + ? await slackActionRuntime.listSlackEmojis(readOpts) + : await slackActionRuntime.listSlackEmojis(); const limit = readNumberParam(params, "limit", { integer: true }); if (limit != null && limit > 0 && result.emoji != null) { const entries = Object.entries(result.emoji).toSorted(([a], [b]) => a.localeCompare(b)); diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index 8920923bc46..c9cf3e9d883 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -1,5 +1,8 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { handleSlackAction, type SlackActionContext } from "../../agents/tools/slack-actions.js"; +import { + handleSlackAction, + type SlackActionContext, +} from "../../../extensions/slack/runtime-api.js"; import { extractSlackToolSend, isSlackInteractiveRepliesEnabled, @@ -7,6 +10,7 @@ import { resolveSlackChannelId, handleSlackMessageAction, } from "../../plugin-sdk/slack.js"; +import { createLegacyMessageToolDiscoveryMethods } from "./message-tool-legacy.js"; import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery } from "./types.js"; @@ -48,6 +52,7 @@ export function createSlackActions( return { describeMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeMessageTool), extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { return await handleSlackMessageAction({ diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 4b78d14480d..bb3dcfe7c59 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -80,4 +80,4 @@ export { export { recordSlackThreadParticipation } from "../../extensions/slack/api.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; export { createSlackActions } from "../channels/plugins/slack.actions.js"; -export type { SlackActionContext } from "../agents/tools/slack-actions.js"; +export type { SlackActionContext } from "../../extensions/slack/runtime-api.js"; diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts index 65b7ed9e884..8c06f2dda34 100644 --- a/src/plugins/runtime/runtime-slack-ops.runtime.ts +++ b/src/plugins/runtime/runtime-slack-ops.runtime.ts @@ -7,7 +7,7 @@ import { probeSlack as probeSlackImpl } from "../../../extensions/slack/runtime- import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/runtime-api.js"; -import { handleSlackAction as handleSlackActionImpl } from "../../agents/tools/slack-actions.js"; +import { handleSlackAction as handleSlackActionImpl } from "../../../extensions/slack/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeSlackOps = Pick< diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index a346a2f8e3a..b3e46b35bb8 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -144,7 +144,7 @@ export type PluginRuntimeChannel = { resolveUserAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackUserAllowlist; sendMessageSlack: typeof import("../../../extensions/slack/runtime-api.js").sendMessageSlack; monitorSlackProvider: typeof import("../../../extensions/slack/runtime-api.js").monitorSlackProvider; - handleSlackAction: typeof import("../../agents/tools/slack-actions.js").handleSlackAction; + handleSlackAction: typeof import("../../../extensions/slack/runtime-api.js").handleSlackAction; }; telegram: { auditGroupMembership: typeof import("../../../extensions/telegram/runtime-api.js").auditTelegramGroupMembership; From 8165db758bfa2fab16f71cfe31be40f2f9c8311e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:07:39 +0000 Subject: [PATCH 16/31] WhatsApp: move action runtime into extension --- extensions/whatsapp/runtime-api.ts | 1 + .../src/action-runtime-target-auth.ts | 8 +++---- .../whatsapp/src/action-runtime.test.ts | 20 +++++++---------- .../whatsapp/src/action-runtime.ts | 22 ++++++++++++++----- src/plugins/runtime/runtime-whatsapp.ts | 4 ++-- src/plugins/runtime/types-channel.ts | 2 +- 6 files changed, 32 insertions(+), 25 deletions(-) rename src/agents/tools/whatsapp-target-auth.ts => extensions/whatsapp/src/action-runtime-target-auth.ts (70%) rename src/agents/tools/whatsapp-actions.test.ts => extensions/whatsapp/src/action-runtime.test.ts (88%) rename src/agents/tools/whatsapp-actions.ts => extensions/whatsapp/src/action-runtime.ts (72%) diff --git a/extensions/whatsapp/runtime-api.ts b/extensions/whatsapp/runtime-api.ts index 24e269ad62f..531cee4b524 100644 --- a/extensions/whatsapp/runtime-api.ts +++ b/extensions/whatsapp/runtime-api.ts @@ -1,4 +1,5 @@ export * from "./src/active-listener.js"; +export * from "./src/action-runtime.js"; export * from "./src/agent-tools-login.js"; export * from "./src/auth-store.js"; export * from "./src/auto-reply.js"; diff --git a/src/agents/tools/whatsapp-target-auth.ts b/extensions/whatsapp/src/action-runtime-target-auth.ts similarity index 70% rename from src/agents/tools/whatsapp-target-auth.ts rename to extensions/whatsapp/src/action-runtime-target-auth.ts index edc0052fbab..8686ac24261 100644 --- a/src/agents/tools/whatsapp-target-auth.ts +++ b/extensions/whatsapp/src/action-runtime-target-auth.ts @@ -1,7 +1,7 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js"; -import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; -import { ToolAuthorizationError } from "./common.js"; +import { ToolAuthorizationError } from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveWhatsAppOutboundTarget } from "../../../src/whatsapp/resolve-outbound-target.js"; +import { resolveWhatsAppAccount } from "./accounts.js"; export function resolveAuthorizedWhatsAppOutboundTarget(params: { cfg: OpenClawConfig; diff --git a/src/agents/tools/whatsapp-actions.test.ts b/extensions/whatsapp/src/action-runtime.test.ts similarity index 88% rename from src/agents/tools/whatsapp-actions.test.ts rename to extensions/whatsapp/src/action-runtime.test.ts index 1fc195ffd1e..b8fb950281a 100644 --- a/src/agents/tools/whatsapp-actions.test.ts +++ b/extensions/whatsapp/src/action-runtime.test.ts @@ -1,17 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; -import { handleWhatsAppAction } from "./whatsapp-actions.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { handleWhatsAppAction, whatsAppActionRuntime } from "./action-runtime.js"; -const { sendReactionWhatsApp, sendPollWhatsApp } = vi.hoisted(() => ({ - sendReactionWhatsApp: vi.fn(async () => undefined), - sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "jid-1" })), -})); - -vi.mock("../../../extensions/whatsapp/src/send.js", () => ({ - sendReactionWhatsApp, - sendPollWhatsApp, -})); +const originalWhatsAppActionRuntime = { ...whatsAppActionRuntime }; +const sendReactionWhatsApp = vi.fn(async () => undefined); const enabledConfig = { channels: { whatsapp: { actions: { reactions: true } } }, @@ -20,6 +13,9 @@ const enabledConfig = { describe("handleWhatsAppAction", () => { beforeEach(() => { vi.clearAllMocks(); + Object.assign(whatsAppActionRuntime, originalWhatsAppActionRuntime, { + sendReactionWhatsApp, + }); }); it("adds reactions", async () => { diff --git a/src/agents/tools/whatsapp-actions.ts b/extensions/whatsapp/src/action-runtime.ts similarity index 72% rename from src/agents/tools/whatsapp-actions.ts rename to extensions/whatsapp/src/action-runtime.ts index a84dc0a3d5b..6a805440633 100644 --- a/src/agents/tools/whatsapp-actions.ts +++ b/extensions/whatsapp/src/action-runtime.ts @@ -1,8 +1,18 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { sendReactionWhatsApp } from "../../plugin-sdk/whatsapp.js"; -import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js"; -import { resolveAuthorizedWhatsAppOutboundTarget } from "./whatsapp-target-auth.js"; +import { + createActionGate, + jsonResult, + readReactionParams, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js"; +import { sendReactionWhatsApp } from "./send.js"; + +export const whatsAppActionRuntime = { + resolveAuthorizedWhatsAppOutboundTarget, + sendReactionWhatsApp, +}; export async function handleWhatsAppAction( params: Record, @@ -26,7 +36,7 @@ export async function handleWhatsAppAction( const fromMe = typeof fromMeRaw === "boolean" ? fromMeRaw : undefined; // Resolve account + allowFrom via shared account logic so auth and routing stay aligned. - const resolved = resolveAuthorizedWhatsAppOutboundTarget({ + const resolved = whatsAppActionRuntime.resolveAuthorizedWhatsAppOutboundTarget({ cfg, chatJid, accountId, @@ -34,7 +44,7 @@ export async function handleWhatsAppAction( }); const resolvedEmoji = remove ? "" : emoji; - await sendReactionWhatsApp(resolved.to, messageId, resolvedEmoji, { + await whatsAppActionRuntime.sendReactionWhatsApp(resolved.to, messageId, resolvedEmoji, { verbose: false, fromMe, participant: participant ?? undefined, diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 5ca70688471..72bb3fd6af0 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -68,7 +68,7 @@ let webLoginQrPromise: Promise< > | null = null; let webChannelPromise: Promise | null = null; let whatsappActionsPromise: Promise< - typeof import("../../agents/tools/whatsapp-actions.js") + typeof import("../../../extensions/whatsapp/runtime-api.js") > | null = null; function loadWebLoginQr() { @@ -82,7 +82,7 @@ function loadWebChannel() { } function loadWhatsAppActions() { - whatsappActionsPromise ??= import("../../agents/tools/whatsapp-actions.js"); + whatsappActionsPromise ??= import("../../../extensions/whatsapp/runtime-api.js"); return whatsappActionsPromise; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index b3e46b35bb8..6b0a0e3a8f6 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -217,7 +217,7 @@ export type PluginRuntimeChannel = { startWebLoginWithQr: typeof import("../../../extensions/whatsapp/login-qr-api.js").startWebLoginWithQr; waitForWebLogin: typeof import("../../../extensions/whatsapp/login-qr-api.js").waitForWebLogin; monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; - handleWhatsAppAction: typeof import("../../agents/tools/whatsapp-actions.js").handleWhatsAppAction; + handleWhatsAppAction: typeof import("../../../extensions/whatsapp/runtime-api.js").handleWhatsAppAction; createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; }; line: { From b5c38b109580f44580669b28372d7c8ebebb1df5 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:07:51 +0000 Subject: [PATCH 17/31] Docs: point message runtime docs and tests at plugin-owned code --- docs/pi.md | 13 ++++-- docs/tools/plugin.md | 7 ++++ src/channels/plugins/actions/actions.test.ts | 6 +-- src/commands/message.test.ts | 41 ++++++++++++------- .../outbound/cfg-threading.guard.test.ts | 2 +- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/docs/pi.md b/docs/pi.md index 2689b480963..f12c687906c 100644 --- a/docs/pi.md +++ b/docs/pi.md @@ -119,19 +119,24 @@ src/agents/ │ ├── browser-tool.ts │ ├── canvas-tool.ts │ ├── cron-tool.ts -│ ├── discord-actions*.ts │ ├── gateway-tool.ts │ ├── image-tool.ts │ ├── message-tool.ts │ ├── nodes-tool.ts │ ├── session*.ts -│ ├── slack-actions.ts -│ ├── telegram-actions.ts │ ├── web-*.ts -│ └── whatsapp-actions.ts +│ └── ... └── ... ``` +Channel-specific message action runtimes now live in the plugin-owned extension +directories instead of under `src/agents/tools`, for example: + +- `extensions/discord/src/actions/runtime*.ts` +- `extensions/slack/src/action-runtime.ts` +- `extensions/telegram/src/action-runtime.ts` +- `extensions/whatsapp/src/action-runtime.ts` + ## Core Integration Flow ### 1. Running an Embedded Agent diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index af4cd1bf6ac..4dc95ae4fe6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -228,6 +228,13 @@ responsible for forwarding the current chat/session identity into the plugin discovery boundary so the shared `message` tool exposes the right channel-owned surface for the current turn. +For channel-owned execution helpers, bundled plugins should keep the execution +runtime inside their own extension modules. Core no longer owns the Discord, +Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`. +`agent-runtime` still re-exports the Discord and Telegram helpers for backward +compatibility, but we do not publish separate `plugin-sdk/*-action-runtime` +subpaths and new plugins should import their own local runtime code directly. + ## Capability ownership model OpenClaw treats a native plugin as the ownership boundary for a **company** or a diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 1692e0f0754..f1ff9c36dfd 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -7,11 +7,11 @@ const sendReactionSignal = vi.fn(async (..._args: unknown[]) => ({ ok: true })); const removeReactionSignal = vi.fn(async (..._args: unknown[]) => ({ ok: true })); const handleSlackAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../../../agents/tools/discord-actions.js", () => ({ +vi.mock("../../../../extensions/discord/src/actions/runtime.js", () => ({ handleDiscordAction, })); -vi.mock("../../../agents/tools/telegram-actions.js", () => ({ +vi.mock("../../../../extensions/telegram/src/action-runtime.js", () => ({ handleTelegramAction, })); @@ -20,7 +20,7 @@ vi.mock("../../../../extensions/signal/src/send-reactions.js", () => ({ removeReactionSignal, })); -vi.mock("../../../agents/tools/slack-actions.js", () => ({ +vi.mock("../../../../extensions/slack/runtime-api.js", () => ({ handleSlackAction, })); diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 182946ba7ad..806dc2655d1 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -18,43 +18,54 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -const resolveCommandSecretRefsViaGateway = vi.fn(async ({ config }: { config: unknown }) => ({ - resolvedConfig: config, - diagnostics: [] as string[], +const { resolveCommandSecretRefsViaGateway, callGatewayMock } = vi.hoisted(() => ({ + resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({ + resolvedConfig: config, + diagnostics: [] as string[], + })), + callGatewayMock: vi.fn(), })); + vi.mock("../cli/command-secret-gateway.js", () => ({ resolveCommandSecretRefsViaGateway, })); -const callGatewayMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ callGateway: callGatewayMock, callGatewayLeastPrivilege: callGatewayMock, randomIdempotencyKey: () => "idem-1", })); -const webAuthExists = vi.fn(async () => false); +const webAuthExists = vi.hoisted(() => vi.fn(async () => false)); vi.mock("../../extensions/whatsapp/src/session.js", () => ({ webAuthExists, })); -const handleDiscordAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/discord-actions.js", () => ({ +const handleDiscordAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/discord/src/actions/runtime.js", () => ({ handleDiscordAction, })); -const handleSlackAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/slack-actions.js", () => ({ +const handleSlackAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/slack/runtime-api.js", () => ({ handleSlackAction, })); -const handleTelegramAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/telegram-actions.js", () => ({ +const handleTelegramAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/telegram/src/action-runtime.js", () => ({ handleTelegramAction, })); -const handleWhatsAppAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/whatsapp-actions.js", () => ({ +const handleWhatsAppAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ handleWhatsAppAction, })); @@ -66,10 +77,12 @@ const setRegistry = async (registry: ReturnType) => { }; beforeEach(async () => { + vi.resetModules(); envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN"]); process.env.TELEGRAM_BOT_TOKEN = ""; process.env.DISCORD_BOT_TOKEN = ""; testConfig = {}; + ({ messageCommand } = await import("./message.js")); await setRegistry(createTestRegistry([])); callGatewayMock.mockClear(); webAuthExists.mockClear().mockResolvedValue(false); @@ -184,7 +197,7 @@ const createTelegramPollPluginRegistration = () => ({ }), }); -const { messageCommand } = await import("./message.js"); +let messageCommand: typeof import("./message.js").messageCommand; function createTelegramSecretRawConfig() { return { diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts index 3fdbb68e10b..cfdbc892db4 100644 --- a/src/infra/outbound/cfg-threading.guard.test.ts +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -61,7 +61,7 @@ function listExtensionFiles(): { function listHighRiskRuntimeCfgFiles(): string[] { return [ - "src/agents/tools/telegram-actions.ts", + "extensions/telegram/src/action-runtime.ts", "extensions/discord/src/monitor/reply-delivery.ts", "extensions/discord/src/monitor/thread-bindings.discord-api.ts", "extensions/discord/src/monitor/thread-bindings.manager.ts", From ed7269518fc783a1201f4e781bcb1a13c2246a67 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:12:53 +0000 Subject: [PATCH 18/31] Tlon: fix plugin-sdk import boundaries --- extensions/tlon/src/monitor/media.ts | 2 +- src/plugin-sdk/tlon.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/tlon/src/monitor/media.ts b/extensions/tlon/src/monitor/media.ts index ea86328d2ce..8a17e982fad 100644 --- a/extensions/tlon/src/monitor/media.ts +++ b/extensions/tlon/src/monitor/media.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { fetchWithSsrFGuard } from "../api.js"; +import { fetchWithSsrFGuard } from "../../api.js"; import { getDefaultSsrFPolicy } from "../urbit/context.js"; // Default to OpenClaw workspace media directory diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 246c4b7093e..291834b9648 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -27,5 +27,5 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createLoggerBackedRuntime } from "./runtime.js"; -export { tlonSetupAdapter } from "../../extensions/tlon/api.js"; -export { tlonSetupWizard } from "../../extensions/tlon/api.js"; +export { tlonSetupAdapter } from "../../extensions/tlon/src/setup-core.js"; +export { tlonSetupWizard } from "../../extensions/tlon/src/setup-surface.js"; From 1c6676cd57453ba8b29a9f908937df7b015beb95 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:17:40 +0000 Subject: [PATCH 19/31] Plugins: remove first-party legacy message discovery shims --- extensions/discord/src/channel-actions.ts | 2 -- extensions/discord/src/channel.ts | 6 ---- extensions/feishu/src/channel.ts | 6 +--- extensions/mattermost/src/channel.ts | 32 +++++++++------------ extensions/msteams/src/channel.ts | 6 +--- extensions/telegram/src/channel-actions.ts | 2 -- extensions/telegram/src/channel.ts | 6 ---- src/channels/plugins/message-tool-legacy.ts | 13 --------- src/channels/plugins/slack.actions.ts | 2 -- src/commands/channels/capabilities.ts | 20 ++++++++++--- src/plugin-sdk/agent-runtime.ts | 12 -------- src/plugin-sdk/channel-runtime.ts | 1 - 12 files changed, 32 insertions(+), 76 deletions(-) delete mode 100644 src/channels/plugins/message-tool-legacy.ts diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 960b08acdf6..c4be7728439 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -1,5 +1,4 @@ import { - createLegacyMessageToolDiscoveryMethods, createDiscordMessageToolComponentsSchema, createUnionActionGate, listTokenSourcedAccounts, @@ -133,7 +132,6 @@ function describeDiscordMessageTool({ export const discordMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeDiscordMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeDiscordMessageTool), extractToolSend: ({ args }) => { const action = typeof args.action === "string" ? args.action.trim() : ""; if (action === "sendMessage") { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index c555ff89382..58076e1e67d 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -79,12 +79,6 @@ function formatDiscordIntents(intents?: { const discordMessageActions: ChannelMessageActionAdapter = { describeMessageTool: (ctx) => getDiscordRuntime().channel.discord.messageActions?.describeMessageTool?.(ctx) ?? null, - listActions: (ctx) => - getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], - getCapabilities: (ctx) => - getDiscordRuntime().channel.discord.messageActions?.getCapabilities?.(ctx) ?? [], - getToolSchema: (ctx) => - getDiscordRuntime().channel.discord.messageActions?.getToolSchema?.(ctx) ?? null, extractToolSend: (ctx) => getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index da5cd8e4382..fda85f113e1 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,10 +1,7 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { - createLegacyMessageToolDiscoveryMethods, - createMessageToolCardSchema, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, @@ -453,7 +450,6 @@ export const feishuPlugin: ChannelPlugin = { }, actions: { describeMessageTool: describeFeishuMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeFeishuMessageTool), handleAction: async (ctx) => { const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined }); if ( diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 5688e13d8ae..4bc716ac27e 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -4,24 +4,8 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { - createLegacyMessageToolDiscoveryMethods, - createMessageToolButtonsSchema, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime"; -import { - buildComputedAccountStatusSnapshot, - buildChannelConfigSchema, - createAccountStatusSink, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, - setAccountEnabledInConfigSection, - type ChannelMessageActionAdapter, - type ChannelMessageActionName, - type ChannelPlugin, -} from "./runtime-api.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; @@ -42,6 +26,19 @@ import { addMattermostReaction, removeMattermostReaction } from "./mattermost/re import { sendMessageMattermost } from "./mattermost/send.js"; import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js"; import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; +import { + buildComputedAccountStatusSnapshot, + buildChannelConfigSchema, + createAccountStatusSink, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + setAccountEnabledInConfigSection, + type ChannelMessageActionAdapter, + type ChannelMessageActionName, + type ChannelPlugin, +} from "./runtime-api.js"; import { getMattermostRuntime } from "./runtime.js"; import { mattermostSetupAdapter } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; @@ -88,7 +85,6 @@ function describeMattermostMessageTool({ const mattermostMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeMattermostMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeMattermostMessageTool), supportsAction: ({ action }) => { return action === "send" || action === "react"; }, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 7458389efb1..5f3a6aa0b59 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,9 +1,6 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { - createLegacyMessageToolDiscoveryMethods, - createMessageToolCardSchema, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, @@ -398,7 +395,6 @@ export const msteamsPlugin: ChannelPlugin = { }, actions: { describeMessageTool: describeMSTeamsMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeMSTeamsMessageTool), handleAction: async (ctx) => { // Handle send action with card parameter if (ctx.action === "send" && ctx.params.card) { diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index cd757688835..56d27817921 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -7,7 +7,6 @@ import { import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import { - createLegacyMessageToolDiscoveryMethods, createMessageToolButtonsSchema, createTelegramPollExtraToolSchemas, createUnionActionGate, @@ -178,7 +177,6 @@ function readTelegramMessageIdParam(params: Record): number { export const telegramMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeTelegramMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeTelegramMessageTool), extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 6d536fb8513..56a2256f9c0 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -250,12 +250,6 @@ function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean { const telegramMessageActions: ChannelMessageActionAdapter = { describeMessageTool: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.describeMessageTool?.(ctx) ?? null, - listActions: (ctx) => - getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [], - getCapabilities: (ctx) => - getTelegramRuntime().channel.telegram.messageActions?.getCapabilities?.(ctx) ?? [], - getToolSchema: (ctx) => - getTelegramRuntime().channel.telegram.messageActions?.getToolSchema?.(ctx) ?? null, extractToolSend: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { diff --git a/src/channels/plugins/message-tool-legacy.ts b/src/channels/plugins/message-tool-legacy.ts deleted file mode 100644 index 2c74213439f..00000000000 --- a/src/channels/plugins/message-tool-legacy.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ChannelMessageActionAdapter } from "./types.js"; - -export function createLegacyMessageToolDiscoveryMethods( - describeMessageTool: NonNullable, -): Pick { - const describe = (ctx: Parameters[0]) => - describeMessageTool(ctx) ?? null; - return { - listActions: (ctx) => [...(describe(ctx)?.actions ?? [])], - getCapabilities: (ctx) => [...(describe(ctx)?.capabilities ?? [])], - getToolSchema: (ctx) => describe(ctx)?.schema ?? null, - }; -} diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index c9cf3e9d883..317b8a7d8db 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -10,7 +10,6 @@ import { resolveSlackChannelId, handleSlackMessageAction, } from "../../plugin-sdk/slack.js"; -import { createLegacyMessageToolDiscoveryMethods } from "./message-tool-legacy.js"; import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery } from "./types.js"; @@ -52,7 +51,6 @@ export function createSlackActions( return { describeMessageTool, - ...createLegacyMessageToolDiscoveryMethods(describeMessageTool), extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { return await handleSlackMessageAction({ diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index acd28137b30..eccd96824da 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -1,5 +1,9 @@ import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; +import { + createMessageActionDiscoveryContext, + resolveMessageActionDiscoveryForPlugin, +} from "../../channels/plugins/message-action-discovery.js"; import type { ChannelCapabilities, ChannelCapabilitiesDiagnostics, @@ -133,10 +137,6 @@ async function resolveChannelReports(params: { : [resolveChannelDefaultAccountId({ plugin, cfg, accountIds: ids })]; })(); const reports: ChannelCapabilitiesReport[] = []; - const listedActions = plugin.actions?.listActions?.({ cfg }) ?? []; - const actions = Array.from( - new Set(["send", "broadcast", ...listedActions.map((action) => String(action))]), - ); for (const accountId of accountIds) { const resolvedAccount = plugin.config.resolveAccount(cfg, accountId); @@ -169,6 +169,18 @@ async function resolveChannelReports(params: { target: params.target, }) : undefined; + const discoveredActions = resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext({ + cfg, + accountId, + }), + includeActions: true, + }).actions; + const actions = Array.from( + new Set(["send", "broadcast", ...discoveredActions.map((action) => String(action))]), + ); reports.push({ channel: plugin.id, diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 20ab0596a12..e267a458e16 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -23,15 +23,3 @@ export * from "../agents/vllm-defaults.js"; // Intentional public runtime surface: channel plugins use ingress agent helpers directly. export * from "../agents/agent-command.js"; export * from "../tts/tts.js"; -// Legacy channel action runtime re-exports. New bundled plugin code should use -// local extension-owned modules instead of adding more public SDK surface here. -export { - handleDiscordAction, - readDiscordParentIdParam, - isDiscordModerationAction, - readDiscordModerationCommand, -} from "../../extensions/discord/runtime-api.js"; -export { - handleTelegramAction, - readTelegramButtons, -} from "../../extensions/telegram/runtime-api.js"; diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index 1460acba87d..089e10609af 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -34,7 +34,6 @@ export type * from "../channels/plugins/types.js"; export * from "../channels/plugins/config-writes.js"; export * from "../channels/plugins/directory-config.js"; export * from "../channels/plugins/media-payload.js"; -export * from "../channels/plugins/message-tool-legacy.js"; export * from "../channels/plugins/message-tool-schema.js"; export * from "../channels/plugins/normalize/signal.js"; export * from "../channels/plugins/normalize/whatsapp.js"; From fb0d04c8342c1ee12b7eaae159573699b9a4bdd9 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:17:47 +0000 Subject: [PATCH 20/31] Tests: migrate channel action discovery to describeMessageTool --- docs/tools/plugin.md | 6 +- extensions/feishu/src/channel.test.ts | 8 ++- extensions/mattermost/src/channel.test.ts | 12 ++-- src/channels/plugins/actions/actions.test.ts | 28 +++++++-- src/channels/plugins/contracts/registry.ts | 30 +++++---- src/channels/plugins/contracts/suites.ts | 42 +++++++++++-- .../plugins/message-capability-matrix.test.ts | 62 ++++++++++--------- 7 files changed, 125 insertions(+), 63 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 4dc95ae4fe6..7d49323892d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -231,9 +231,9 @@ surface for the current turn. For channel-owned execution helpers, bundled plugins should keep the execution runtime inside their own extension modules. Core no longer owns the Discord, Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`. -`agent-runtime` still re-exports the Discord and Telegram helpers for backward -compatibility, but we do not publish separate `plugin-sdk/*-action-runtime` -subpaths and new plugins should import their own local runtime code directly. +We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled +plugins should import their own local runtime code directly from their +extension-owned modules. ## Capability ownership model diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 7c4ae5d877a..df105f81919 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -54,6 +54,10 @@ vi.mock("./channel.runtime.js", () => ({ import { feishuPlugin } from "./channel.js"; +function getDescribedActions(cfg: OpenClawConfig): string[] { + return [...(feishuPlugin.actions?.describeMessageTool?.({ cfg })?.actions ?? [])]; +} + describe("feishuPlugin.status.probeAccount", () => { it("uses current account credentials for multi-account config", async () => { const cfg = { @@ -112,7 +116,7 @@ describe("feishuPlugin actions", () => { }); it("advertises the expanded Feishu action surface", () => { - expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual([ + expect(getDescribedActions(cfg)).toEqual([ "send", "read", "edit", @@ -142,7 +146,7 @@ describe("feishuPlugin actions", () => { }, } as OpenClawConfig; - expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([ + expect(getDescribedActions(disabledCfg)).toEqual([ "send", "read", "edit", diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 5ac333b2e6c..29c4cc12e0e 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -17,6 +17,10 @@ import { withMockedGlobalFetch, } from "./mattermost/reactions.test-helpers.js"; +function getDescribedActions(cfg: OpenClawConfig): string[] { + return [...(mattermostPlugin.actions?.describeMessageTool?.({ cfg })?.actions ?? [])]; +} + describe("mattermostPlugin", () => { beforeEach(() => { sendMessageMattermostMock.mockReset(); @@ -132,7 +136,7 @@ describe("mattermostPlugin", () => { }, }; - const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + const actions = getDescribedActions(cfg); expect(actions).toContain("react"); expect(actions).toContain("send"); expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true); @@ -148,7 +152,7 @@ describe("mattermostPlugin", () => { }, }; - const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + const actions = getDescribedActions(cfg); expect(actions).toEqual([]); }); @@ -164,7 +168,7 @@ describe("mattermostPlugin", () => { }, }; - const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + const actions = getDescribedActions(cfg); expect(actions).not.toContain("react"); expect(actions).toContain("send"); }); @@ -187,7 +191,7 @@ describe("mattermostPlugin", () => { }, }; - const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? []; + const actions = getDescribedActions(cfg); expect(actions).toContain("react"); }); diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index f1ff9c36dfd..b4631d03f2c 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; +import type { ChannelMessageActionAdapter } from "../types.js"; const handleDiscordAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); const handleTelegramAction = vi.fn(async (..._args: unknown[]) => ({ ok: true })); @@ -30,6 +31,13 @@ let telegramMessageActions: typeof import("./telegram.js").telegramMessageAction let signalMessageActions: typeof import("./signal.js").signalMessageActions; let createSlackActions: typeof import("../slack.actions.js").createSlackActions; +function getDescribedActions(params: { + describeMessageTool?: ChannelMessageActionAdapter["describeMessageTool"]; + cfg: OpenClawConfig; +}) { + return [...(params.describeMessageTool?.({ cfg: params.cfg })?.actions ?? [])]; +} + function telegramCfg(): OpenClawConfig { return { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; } @@ -284,7 +292,10 @@ describe("discord message actions", () => { ] as const; for (const testCase of cases) { - const actions = discordMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + const actions = getDescribedActions({ + describeMessageTool: discordMessageActions.describeMessageTool, + cfg: testCase.cfg, + }); if (testCase.expectUploads) { expect(actions, testCase.name).toContain("emoji-upload"); expect(actions, testCase.name).toContain("sticker-upload"); @@ -629,7 +640,10 @@ describe("telegramMessageActions", () => { expectTopicEdit: true, }, ]) { - const actions = telegramMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + const actions = getDescribedActions({ + describeMessageTool: telegramMessageActions.describeMessageTool, + cfg: testCase.cfg, + }); if (testCase.expectPoll) { expect(actions, testCase.name).toContain("poll"); } else { @@ -680,7 +694,10 @@ describe("telegramMessageActions", () => { ] as const; for (const testCase of cases) { - const actions = telegramMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + const actions = getDescribedActions({ + describeMessageTool: telegramMessageActions.describeMessageTool, + cfg: testCase.cfg, + }); if (testCase.expectSticker) { expect(actions, testCase.name).toContain("sticker"); expect(actions, testCase.name).toContain("sticker-search"); @@ -903,7 +920,10 @@ describe("telegramMessageActions", () => { }, }, } as OpenClawConfig; - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; + const actions = getDescribedActions({ + describeMessageTool: telegramMessageActions.describeMessageTool, + cfg, + }); expect(actions).toContain("sticker"); expect(actions).toContain("sticker-search"); diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index fd2d84e8b70..8b203c9b541 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -174,17 +174,14 @@ function expectClearedSessionBinding(params: { ).toBeNull(); } -const telegramListActionsMock = vi.fn(); -const telegramGetCapabilitiesMock = vi.fn(); -const discordListActionsMock = vi.fn(); -const discordGetCapabilitiesMock = vi.fn(); +const telegramDescribeMessageToolMock = vi.fn(); +const discordDescribeMessageToolMock = vi.fn(); bundledChannelRuntimeSetters.setTelegramRuntime({ channel: { telegram: { messageActions: { - listActions: telegramListActionsMock, - getCapabilities: telegramGetCapabilitiesMock, + describeMessageTool: telegramDescribeMessageToolMock, }, }, }, @@ -194,8 +191,7 @@ bundledChannelRuntimeSetters.setDiscordRuntime({ channel: { discord: { messageActions: { - listActions: discordListActionsMock, - getCapabilities: discordGetCapabilitiesMock, + describeMessageTool: discordDescribeMessageToolMock, }, }, }, @@ -358,10 +354,11 @@ export const actionContractRegistry: ActionsContractEntry[] = [ expectedActions: ["send", "poll", "react"], expectedCapabilities: ["interactive", "buttons"], beforeTest: () => { - telegramListActionsMock.mockReset(); - telegramGetCapabilitiesMock.mockReset(); - telegramListActionsMock.mockReturnValue(["send", "poll", "react"]); - telegramGetCapabilitiesMock.mockReturnValue(["interactive", "buttons"]); + telegramDescribeMessageToolMock.mockReset(); + telegramDescribeMessageToolMock.mockReturnValue({ + actions: ["send", "poll", "react"], + capabilities: ["interactive", "buttons"], + }); }, }, ], @@ -376,10 +373,11 @@ export const actionContractRegistry: ActionsContractEntry[] = [ expectedActions: ["send", "react", "poll"], expectedCapabilities: ["interactive", "components"], beforeTest: () => { - discordListActionsMock.mockReset(); - discordGetCapabilitiesMock.mockReset(); - discordListActionsMock.mockReturnValue(["send", "react", "poll"]); - discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]); + discordDescribeMessageToolMock.mockReset(); + discordDescribeMessageToolMock.mockReturnValue({ + actions: ["send", "react", "poll"], + capabilities: ["interactive", "components"], + }); }, }, ], diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index cc442b5ef20..58a62d62ed3 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -32,6 +32,30 @@ function sortStrings(values: readonly string[]) { return [...values].toSorted((left, right) => left.localeCompare(right)); } +function resolveContractMessageDiscovery(params: { + plugin: Pick; + cfg: OpenClawConfig; +}) { + const actions = params.plugin.actions; + if (!actions) { + return { + actions: [] as ChannelMessageActionName[], + 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 : [], + }; + } + return { + actions: actions.listActions?.({ cfg: params.cfg }) ?? [], + capabilities: actions.getCapabilities?.({ cfg: params.cfg }) ?? [], + }; +} + const contractRuntime = createNonExitingRuntime(); function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) { expect(["user", "group", "channel"]).toContain(entry.kind); @@ -132,15 +156,22 @@ export function installChannelActionsContractSuite(params: { }) { it("exposes the base message actions contract", () => { expect(params.plugin.actions).toBeDefined(); - expect(typeof params.plugin.actions?.listActions).toBe("function"); + expect( + typeof params.plugin.actions?.describeMessageTool === "function" || + typeof params.plugin.actions?.listActions === "function", + ).toBe(true); }); for (const testCase of params.cases) { it(`actions contract: ${testCase.name}`, () => { testCase.beforeTest?.(); - const actions = params.plugin.actions?.listActions?.({ cfg: testCase.cfg }) ?? []; - const capabilities = params.plugin.actions?.getCapabilities?.({ cfg: testCase.cfg }) ?? []; + const discovery = resolveContractMessageDiscovery({ + plugin: params.plugin, + cfg: testCase.cfg, + }); + const actions = discovery.actions; + const capabilities = discovery.capabilities; expect(actions).toEqual([...new Set(actions)]); expect(capabilities).toEqual([...new Set(capabilities)]); @@ -192,7 +223,10 @@ export function installChannelSurfaceContractSuite(params: { it(`exposes the ${surface} surface contract`, () => { if (surface === "actions") { expect(plugin.actions).toBeDefined(); - expect(typeof plugin.actions?.listActions).toBe("function"); + expect( + typeof plugin.actions?.describeMessageTool === "function" || + typeof plugin.actions?.listActions === "function", + ).toBe(true); return; } diff --git a/src/channels/plugins/message-capability-matrix.test.ts b/src/channels/plugins/message-capability-matrix.test.ts index 9ab42ad4c51..459193d0792 100644 --- a/src/channels/plugins/message-capability-matrix.test.ts +++ b/src/channels/plugins/message-capability-matrix.test.ts @@ -1,15 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import type { ChannelMessageActionAdapter, ChannelPlugin } from "./types.js"; -const telegramGetCapabilitiesMock = vi.fn(); -const discordGetCapabilitiesMock = vi.fn(); +const telegramDescribeMessageToolMock = vi.fn(); +const discordDescribeMessageToolMock = vi.fn(); vi.mock("../../../extensions/telegram/src/runtime.js", () => ({ getTelegramRuntime: () => ({ channel: { telegram: { messageActions: { - getCapabilities: telegramGetCapabilitiesMock, + describeMessageTool: telegramDescribeMessageToolMock, }, }, }, @@ -21,7 +22,7 @@ vi.mock("../../../extensions/discord/src/runtime.js", () => ({ channel: { discord: { messageActions: { - getCapabilities: discordGetCapabilitiesMock, + describeMessageTool: discordDescribeMessageToolMock, }, }, }, @@ -38,10 +39,16 @@ const { zaloPlugin } = await import("../../../extensions/zalo/src/channel.js"); describe("channel action capability matrix", () => { afterEach(() => { - telegramGetCapabilitiesMock.mockReset(); - discordGetCapabilitiesMock.mockReset(); + telegramDescribeMessageToolMock.mockReset(); + discordDescribeMessageToolMock.mockReset(); }); + function getCapabilities(plugin: Pick, cfg: OpenClawConfig) { + const describeMessageTool: ChannelMessageActionAdapter["describeMessageTool"] | undefined = + plugin.actions?.describeMessageTool; + return [...(describeMessageTool?.({ cfg })?.capabilities ?? [])]; + } + it("exposes Slack blocks by default and interactive when enabled", () => { const baseCfg = { channels: { @@ -61,26 +68,27 @@ describe("channel action capability matrix", () => { }, } as OpenClawConfig; - expect(slackPlugin.actions?.getCapabilities?.({ cfg: baseCfg })).toEqual(["blocks"]); - expect(slackPlugin.actions?.getCapabilities?.({ cfg: interactiveCfg })).toEqual([ - "blocks", - "interactive", - ]); + expect(getCapabilities(slackPlugin, baseCfg)).toEqual(["blocks"]); + expect(getCapabilities(slackPlugin, interactiveCfg)).toEqual(["blocks", "interactive"]); }); it("forwards Telegram action capabilities through the channel wrapper", () => { - telegramGetCapabilitiesMock.mockReturnValue(["interactive", "buttons"]); + telegramDescribeMessageToolMock.mockReturnValue({ + capabilities: ["interactive", "buttons"], + }); - const result = telegramPlugin.actions?.getCapabilities?.({ cfg: {} as OpenClawConfig }); + const result = getCapabilities(telegramPlugin, {} as OpenClawConfig); expect(result).toEqual(["interactive", "buttons"]); - expect(telegramGetCapabilitiesMock).toHaveBeenCalledWith({ cfg: {} }); - discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]); + expect(telegramDescribeMessageToolMock).toHaveBeenCalledWith({ cfg: {} }); + discordDescribeMessageToolMock.mockReturnValue({ + capabilities: ["interactive", "components"], + }); - const discordResult = discordPlugin.actions?.getCapabilities?.({ cfg: {} as OpenClawConfig }); + const discordResult = getCapabilities(discordPlugin, {} as OpenClawConfig); expect(discordResult).toEqual(["interactive", "components"]); - expect(discordGetCapabilitiesMock).toHaveBeenCalledWith({ cfg: {} }); + expect(discordDescribeMessageToolMock).toHaveBeenCalledWith({ cfg: {} }); }); it("exposes configured channel capabilities only when required credentials are present", () => { @@ -139,18 +147,12 @@ describe("channel action capability matrix", () => { }, } as OpenClawConfig; - expect(mattermostPlugin.actions?.getCapabilities?.({ cfg: configuredCfg })).toEqual([ - "buttons", - ]); - expect(mattermostPlugin.actions?.getCapabilities?.({ cfg: unconfiguredCfg })).toEqual([]); - expect(feishuPlugin.actions?.getCapabilities?.({ cfg: configuredFeishuCfg })).toEqual([ - "cards", - ]); - expect(feishuPlugin.actions?.getCapabilities?.({ cfg: disabledFeishuCfg })).toEqual([]); - expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: configuredMsteamsCfg })).toEqual([ - "cards", - ]); - expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: disabledMsteamsCfg })).toEqual([]); + expect(getCapabilities(mattermostPlugin, configuredCfg)).toEqual(["buttons"]); + expect(getCapabilities(mattermostPlugin, unconfiguredCfg)).toEqual([]); + expect(getCapabilities(feishuPlugin, configuredFeishuCfg)).toEqual(["cards"]); + expect(getCapabilities(feishuPlugin, disabledFeishuCfg)).toEqual([]); + expect(getCapabilities(msteamsPlugin, configuredMsteamsCfg)).toEqual(["cards"]); + expect(getCapabilities(msteamsPlugin, disabledMsteamsCfg)).toEqual([]); }); it("keeps Zalo actions on the empty capability set", () => { @@ -163,6 +165,6 @@ describe("channel action capability matrix", () => { }, } as OpenClawConfig; - expect(zaloPlugin.actions?.getCapabilities?.({ cfg })).toEqual([]); + expect(getCapabilities(zaloPlugin, cfg)).toEqual([]); }); }); From 8e98019b6af9d128cd32e2635c0a4386e386887b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:17:56 +0000 Subject: [PATCH 21/31] Nostr: remove plugin API import cycle --- extensions/nostr/src/channel.ts | 10 +++++----- extensions/nostr/src/config-schema.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 4296f71b9ac..21dfce3a9da 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -1,7 +1,3 @@ -import { - buildPassiveChannelStatusSummary, - buildTrafficStatusSummary, -} from "../../shared/channel-status-summary.js"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -10,7 +6,11 @@ import { formatPairingApproveHint, mapAllowFromEntries, type ChannelPlugin, -} from "../api.js"; +} from "openclaw/plugin-sdk/nostr"; +import { + buildPassiveChannelStatusSummary, + buildTrafficStatusSummary, +} from "../../shared/channel-status-summary.js"; import type { NostrProfile } from "./config-schema.js"; import { NostrConfigSchema } from "./config-schema.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 2746d518fe6..53346b0789d 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,6 +1,6 @@ import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/channel-config-schema"; +import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr"; import { z } from "zod"; -import { MarkdownConfigSchema, buildChannelConfigSchema } from "../api.js"; /** * Validates https:// URLs only (no javascript:, data:, file:, etc.) From 09de192b770e851f9b5810b0818972b56c62f19f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:18:02 +0000 Subject: [PATCH 22/31] Tlon: import channel account snapshot type --- extensions/tlon/src/channel.runtime.ts | 7 ++++++- extensions/tlon/src/channel.ts | 2 +- extensions/tlon/src/config-schema.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index c6523f61739..98da82480fa 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -1,6 +1,11 @@ import crypto from "node:crypto"; import { configureClient } from "@tloncorp/api"; -import type { ChannelOutboundAdapter, ChannelPlugin, OpenClawConfig } from "../api.js"; +import type { + ChannelAccountSnapshot, + ChannelOutboundAdapter, + ChannelPlugin, + OpenClawConfig, +} from "../api.js"; import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../api.js"; import { monitorTlonProvider } from "./monitor/index.js"; import { tlonSetupWizard } from "./setup-surface.js"; diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 0e22d237589..92d22feedd5 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,4 +1,5 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "../api.js"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { applyTlonSetupConfig, @@ -13,7 +14,6 @@ import { resolveTlonOutboundTarget, } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; -import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "../api.js"; import { validateUrbitBaseUrl } from "./urbit/base-url.js"; const TLON_CHANNEL_ID = "tlon" as const; diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 7f12949f30d..e7ec5ef2ecf 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -1,5 +1,5 @@ -import { buildChannelConfigSchema } from "../api.js"; import { z } from "zod"; +import { buildChannelConfigSchema } from "../api.js"; const ShipSchema = z.string().min(1); const ChannelNestSchema = z.string().min(1); From bb803a42acb6c7ef90b1c902a117f3bd27e99756 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:18:06 +0000 Subject: [PATCH 23/31] Mattermost: normalize plugin imports --- extensions/mattermost/src/config-schema.ts | 4 ++-- extensions/mattermost/src/group-mentions.ts | 2 +- extensions/mattermost/src/mattermost/directory.ts | 6 +----- extensions/mattermost/src/mattermost/interactions.ts | 6 +----- extensions/mattermost/src/mattermost/monitor-websocket.ts | 2 +- extensions/mattermost/src/mattermost/slash-http.ts | 2 +- extensions/mattermost/src/runtime.ts | 2 +- extensions/mattermost/src/setup-core.ts | 4 ++-- extensions/mattermost/src/setup-surface.ts | 8 ++++---- 9 files changed, 14 insertions(+), 22 deletions(-) diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index bd1f42dfd7f..e8e50371bd4 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; +import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { BlockStreamingCoalesceSchema, DmPolicySchema, @@ -5,8 +7,6 @@ import { MarkdownConfigSchema, requireOpenAllowFrom, } from "./runtime-api.js"; -import { z } from "zod"; -import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { buildSecretInputSchema } from "./secret-input.js"; const DmChannelRetrySchema = z diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts index 4996d115371..4d8d484d89c 100644 --- a/extensions/mattermost/src/group-mentions.ts +++ b/extensions/mattermost/src/group-mentions.ts @@ -1,6 +1,6 @@ import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; -import type { ChannelGroupContext } from "./runtime-api.js"; import { resolveMattermostAccount } from "./mattermost/accounts.js"; +import type { ChannelGroupContext } from "./runtime-api.js"; export function resolveMattermostGroupRequireMention( params: ChannelGroupContext & { requireMentionOverride?: boolean }, diff --git a/extensions/mattermost/src/mattermost/directory.ts b/extensions/mattermost/src/mattermost/directory.ts index 630ed7c7194..da6ce747f52 100644 --- a/extensions/mattermost/src/mattermost/directory.ts +++ b/extensions/mattermost/src/mattermost/directory.ts @@ -1,8 +1,4 @@ -import type { - ChannelDirectoryEntry, - OpenClawConfig, - RuntimeEnv, -} from "../runtime-api.js"; +import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv } from "../runtime-api.js"; import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index a51002667f8..fe11d037396 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -1,10 +1,6 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { - isTrustedProxyAddress, - resolveClientIp, - type OpenClawConfig, -} from "../runtime-api.js"; +import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "../runtime-api.js"; import { getMattermostRuntime } from "../runtime.js"; import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.ts b/extensions/mattermost/src/mattermost/monitor-websocket.ts index c04affbae1d..09a1248c8cf 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.ts @@ -1,5 +1,5 @@ -import type { ChannelAccountSnapshot, RuntimeEnv } from "../runtime-api.js"; import WebSocket from "ws"; +import type { ChannelAccountSnapshot, RuntimeEnv } from "../runtime-api.js"; import type { MattermostPost } from "./client.js"; import { rawDataToString } from "./monitor-helpers.js"; diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 401cc56172a..4d4d5f502a3 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -6,6 +6,7 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { buildModelsProviderData, createReplyPrefixOptions, @@ -17,7 +18,6 @@ import { type ReplyPayload, type RuntimeEnv, } from "../runtime-api.js"; -import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { getMattermostRuntime } from "../runtime.js"; import { createMattermostClient, diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts index e238fa963e2..1fb88e059b7 100644 --- a/extensions/mattermost/src/runtime.ts +++ b/extensions/mattermost/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "./runtime-api.js"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "./runtime-api.js"; const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = createPluginRuntimeStore("Mattermost runtime not initialized"); diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 13a4991fcd0..624a31a48c4 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -1,4 +1,6 @@ import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { applyAccountNameToChannelSection, applySetupAccountConfigPatch, @@ -8,8 +10,6 @@ import { normalizeAccountId, type OpenClawConfig, } from "./runtime-api.js"; -import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; -import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; const channel = "mattermost" as const; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index 385c4dc75e3..a439dd15006 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -1,13 +1,13 @@ +import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup"; +import { listMattermostAccountIds } from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { applySetupAccountConfigPatch, DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, type OpenClawConfig, } from "./runtime-api.js"; -import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "openclaw/plugin-sdk/setup"; -import { listMattermostAccountIds } from "./mattermost/accounts.js"; -import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { isMattermostConfigured, mattermostSetupAdapter, From 9e8b9aba1fc3f482e4fe12bb236ce42690597efd Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:20:57 +0000 Subject: [PATCH 24/31] WhatsApp: isolate lazy action runtime boundary --- extensions/whatsapp/action-runtime.runtime.ts | 1 + src/plugins/runtime/runtime-whatsapp.ts | 4 ++-- src/plugins/runtime/types-channel.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 extensions/whatsapp/action-runtime.runtime.ts diff --git a/extensions/whatsapp/action-runtime.runtime.ts b/extensions/whatsapp/action-runtime.runtime.ts new file mode 100644 index 00000000000..aeb44fc866b --- /dev/null +++ b/extensions/whatsapp/action-runtime.runtime.ts @@ -0,0 +1 @@ +export { handleWhatsAppAction } from "./src/action-runtime.js"; diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 72bb3fd6af0..ba653942550 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -68,7 +68,7 @@ let webLoginQrPromise: Promise< > | null = null; let webChannelPromise: Promise | null = null; let whatsappActionsPromise: Promise< - typeof import("../../../extensions/whatsapp/runtime-api.js") + typeof import("../../../extensions/whatsapp/action-runtime.runtime.js") > | null = null; function loadWebLoginQr() { @@ -82,7 +82,7 @@ function loadWebChannel() { } function loadWhatsAppActions() { - whatsappActionsPromise ??= import("../../../extensions/whatsapp/runtime-api.js"); + whatsappActionsPromise ??= import("../../../extensions/whatsapp/action-runtime.runtime.js"); return whatsappActionsPromise; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 6b0a0e3a8f6..f13dd010c0e 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -217,7 +217,7 @@ export type PluginRuntimeChannel = { startWebLoginWithQr: typeof import("../../../extensions/whatsapp/login-qr-api.js").startWebLoginWithQr; waitForWebLogin: typeof import("../../../extensions/whatsapp/login-qr-api.js").waitForWebLogin; monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; - handleWhatsAppAction: typeof import("../../../extensions/whatsapp/runtime-api.js").handleWhatsAppAction; + handleWhatsAppAction: typeof import("../../../extensions/whatsapp/action-runtime.runtime.js").handleWhatsAppAction; createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; }; line: { From fa73f5aeb5fb6f7befe1e50216dc46e7444452c0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:34:25 +0000 Subject: [PATCH 25/31] Polls: defer shared parsing until plugin fallback --- docs/tools/plugin.md | 11 +++ src/channels/plugins/types.adapters.ts | 4 + src/channels/plugins/types.core.ts | 4 + ...sage-action-runner.plugin-dispatch.test.ts | 93 +++++++++++++++++++ .../message-action-runner.poll.test.ts | 27 ++++-- src/infra/outbound/message-action-runner.ts | 74 ++++++++------- .../outbound/outbound-send-service.test.ts | 64 +++++++++---- src/infra/outbound/outbound-send-service.ts | 35 +++---- 8 files changed, 236 insertions(+), 76 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7d49323892d..b50797537a6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -235,6 +235,17 @@ We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled plugins should import their own local runtime code directly from their extension-owned modules. +For polls specifically, there are two execution paths: + +- `outbound.sendPoll` is the shared baseline for channels that fit the common + poll model +- `actions.handleAction("poll")` is the preferred path for channel-specific + poll semantics or extra poll parameters + +Core now defers shared poll parsing until after plugin poll dispatch declines +the action, so plugin-owned poll handlers can accept channel-specific poll +fields without being blocked by the generic poll parser first. + ## Capability ownership model OpenClaw treats a native plugin as the ownership boundary for a **company** or a diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index c31d6057223..7274d612c7c 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -170,6 +170,10 @@ export type ChannelOutboundAdapter = { ) => Promise; sendText?: (ctx: ChannelOutboundContext) => Promise; sendMedia?: (ctx: ChannelOutboundContext) => Promise; + /** + * Shared outbound poll adapter for channels that fit the common poll model. + * Channels with extra poll semantics should prefer `actions.handleAction("poll")`. + */ sendPoll?: (ctx: ChannelPollContext) => Promise; }; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 1699b8024a5..24c5c96708e 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -521,6 +521,10 @@ export type ChannelMessageActionAdapter = { toolContext?: ChannelThreadingToolContext; }) => boolean; extractToolSend?: (params: { args: Record }) => ChannelToolSend | null; + /** + * Prefer this for channel-specific poll semantics or extra poll parameters. + * Core only parses the shared poll model when falling back to `outbound.sendPoll`. + */ handleAction?: (ctx: ChannelMessageActionContext) => Promise>; }; 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 f875bb40487..55290b8d9d1 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -408,6 +408,99 @@ describe("runMessageAction plugin dispatch", () => { }); }); + describe("plugin-owned poll semantics", () => { + const handleAction = vi.fn(async ({ params }: { params: Record }) => + jsonResult({ + ok: true, + forwarded: { + to: params.to ?? null, + pollQuestion: params.pollQuestion ?? null, + pollOption: params.pollOption ?? null, + pollDurationSeconds: params.pollDurationSeconds ?? null, + pollPublic: params.pollPublic ?? null, + }, + }), + ); + + const discordPollPlugin: ChannelPlugin = { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord plugin-owned poll test plugin.", + }, + capabilities: { chatTypes: ["direct"] }, + config: createAlwaysConfiguredPluginConfig(), + messaging: { + targetResolver: { + looksLikeId: () => true, + }, + }, + actions: { + supportsAction: ({ action }) => action === "poll", + handleAction, + }, + }; + + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: discordPollPlugin, + }, + ]), + ); + handleAction.mockClear(); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + vi.clearAllMocks(); + }); + + it("lets non-telegram plugins own extra poll fields", async () => { + const result = await runMessageAction({ + cfg: { + channels: { + discord: { + token: "tok", + }, + }, + } as OpenClawConfig, + action: "poll", + params: { + channel: "discord", + target: "channel:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 120, + pollPublic: true, + }, + dryRun: false, + }); + + expect(result.kind).toBe("poll"); + expect(result.handledBy).toBe("plugin"); + expect(handleAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "poll", + channel: "discord", + params: expect.objectContaining({ + to: "channel:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 120, + pollPublic: true, + }), + }), + ); + }); + }); + describe("components parsing", () => { const handleAction = vi.fn(async ({ params }: { params: Record }) => jsonResult({ diff --git a/src/infra/outbound/message-action-runner.poll.test.ts b/src/infra/outbound/message-action-runner.poll.test.ts index ed1beb91f5d..a46e66dd872 100644 --- a/src/infra/outbound/message-action-runner.poll.test.ts +++ b/src/infra/outbound/message-action-runner.poll.test.ts @@ -34,15 +34,24 @@ async function runPollAction(params: { params: params.actionParams as never, toolContext: params.toolContext as never, }); - return mocks.executePollAction.mock.calls[0]?.[0] as + const call = mocks.executePollAction.mock.calls[0]?.[0] as | { - durationSeconds?: number; - maxSelections?: number; - threadId?: string; - isAnonymous?: boolean; + resolveCorePoll?: () => { + durationSeconds?: number; + maxSelections?: number; + threadId?: string; + isAnonymous?: boolean; + }; ctx?: { params?: Record }; } | undefined; + if (!call) { + return undefined; + } + return { + ...call.resolveCorePoll?.(), + ctx: call.ctx, + }; } describe("runMessageAction poll handling", () => { beforeEach(async () => { @@ -55,11 +64,11 @@ describe("runMessageAction poll handling", () => { telegramConfig, } = await import("./message-action-runner.test-helpers.js")); installMessageActionRunnerTestRegistry(); - mocks.executePollAction.mockResolvedValue({ + mocks.executePollAction.mockImplementation(async (input) => ({ handledBy: "core", - payload: { ok: true }, + payload: { ok: true, corePoll: input.resolveCorePoll() }, pollResult: { ok: true }, - }); + })); }); afterEach(() => { @@ -105,7 +114,7 @@ describe("runMessageAction poll handling", () => { }, ])("$name", async ({ getCfg, actionParams, message }) => { await expect(runPollAction({ cfg: getCfg(), actionParams })).rejects.toThrow(message); - expect(mocks.executePollAction).not.toHaveBeenCalled(); + expect(mocks.executePollAction).toHaveBeenCalledTimes(1); }); it("passes Telegram durationSeconds, visibility, and auto threadId to executePollAction", async () => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 70646a288a2..1777fbb32e3 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -591,34 +591,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise { + const question = readStringParam(params, "pollQuestion", { + required: true, + }); + const options = readStringArrayParam(params, "pollOption", { required: true }); + if (options.length < 2) { + throw new Error("pollOption requires at least two values"); + } + const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false; + const pollAnonymous = readBooleanParam(params, "pollAnonymous"); + const pollPublic = readBooleanParam(params, "pollPublic"); + const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic }); + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + strict: true, + }); + const durationSeconds = readNumberParam(params, "pollDurationSeconds", { + integer: true, + strict: true, + }); + + if (durationSeconds !== undefined && channel !== "telegram") { + throw new Error("pollDurationSeconds is only supported for Telegram polls"); + } + if (isAnonymous !== undefined && channel !== "telegram") { + throw new Error("pollAnonymous/pollPublic are only supported for Telegram polls"); + } + + return { + to, + question, + options, + maxSelections: resolvePollMaxSelections(options.length, allowMultiselect), + durationSeconds: durationSeconds ?? undefined, + durationHours: durationHours ?? undefined, + threadId: resolvedThreadId ?? undefined, + isAnonymous, + }; + }, }); return { diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index 3f3fd0f2fcc..6f0cf32e6e5 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -143,16 +143,44 @@ describe("executeSendAction", () => { params: {}, dryRun: false, }, - to: "channel:123", - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, + resolveCorePoll: () => ({ + to: "channel:123", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + }), }); expect(result.handledBy).toBe("plugin"); expect(mocks.sendPoll).not.toHaveBeenCalled(); }); + it("does not invoke shared poll parsing before plugin poll dispatch", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("poll-plugin")); + const resolveCorePoll = vi.fn(() => { + throw new Error("shared poll fallback should not run"); + }); + + const result = await executePollAction({ + ctx: { + cfg: {}, + channel: "discord", + params: { + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 90, + pollPublic: true, + }, + dryRun: false, + }, + resolveCorePoll, + }); + + expect(result.handledBy).toBe("plugin"); + expect(resolveCorePoll).not.toHaveBeenCalled(); + expect(mocks.sendPoll).not.toHaveBeenCalled(); + }); + it("passes agent-scoped media local roots to plugin dispatch", async () => { mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); @@ -270,13 +298,15 @@ describe("executeSendAction", () => { accountId: "acc-1", dryRun: false, }, - to: "channel:123", - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, - durationSeconds: 300, - threadId: "thread-1", - isAnonymous: true, + resolveCorePoll: () => ({ + to: "channel:123", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + durationSeconds: 300, + threadId: "thread-1", + isAnonymous: true, + }), }); expect(mocks.sendPoll).toHaveBeenCalledWith( @@ -321,11 +351,13 @@ describe("executeSendAction", () => { mode: GATEWAY_CLIENT_MODES.BACKEND, }, }, - to: "channel:123", - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, - durationHours: 6, + resolveCorePoll: () => ({ + to: "channel:123", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + durationHours: 6, + }), }); expect(mocks.dispatchChannelMessageAction).not.toHaveBeenCalled(); diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 5d518798afa..b56fade5923 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -152,14 +152,16 @@ export async function executeSendAction(params: { export async function executePollAction(params: { ctx: OutboundSendContext; - to: string; - question: string; - options: string[]; - maxSelections: number; - durationSeconds?: number; - durationHours?: number; - threadId?: string; - isAnonymous?: boolean; + resolveCorePoll: () => { + to: string; + question: string; + options: string[]; + maxSelections: number; + durationSeconds?: number; + durationHours?: number; + threadId?: string; + isAnonymous?: boolean; + }; }): Promise<{ handledBy: "plugin" | "core"; payload: unknown; @@ -174,19 +176,20 @@ export async function executePollAction(params: { return pluginHandled; } + const corePoll = params.resolveCorePoll(); const result: MessagePollResult = await sendPoll({ cfg: params.ctx.cfg, - to: params.to, - question: params.question, - options: params.options, - maxSelections: params.maxSelections, - durationSeconds: params.durationSeconds ?? undefined, - durationHours: params.durationHours ?? undefined, + to: corePoll.to, + question: corePoll.question, + options: corePoll.options, + maxSelections: corePoll.maxSelections, + durationSeconds: corePoll.durationSeconds ?? undefined, + durationHours: corePoll.durationHours ?? undefined, channel: params.ctx.channel, accountId: params.ctx.accountId ?? undefined, - threadId: params.threadId ?? undefined, + threadId: corePoll.threadId ?? undefined, silent: params.ctx.silent ?? undefined, - isAnonymous: params.isAnonymous ?? undefined, + isAnonymous: corePoll.isAnonymous ?? undefined, dryRun: params.ctx.dryRun, gateway: params.ctx.gateway, }); From d8b95d2315eb54a6163f83185ad14f00d3aa73be Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:34:33 +0000 Subject: [PATCH 26/31] Polls: scope Telegram poll extras to plugin schema --- src/agents/tools/message-tool.ts | 7 ++---- src/channels/plugins/message-tool-schema.ts | 1 - src/poll-params.ts | 25 ++++++++++++++++----- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 77703d8ee75..6c1718fd8eb 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -18,7 +18,7 @@ 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 { POLL_CREATION_PARAM_DEFS, POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; +import { POLL_CREATION_PARAM_DEFS, SHARED_POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; @@ -197,11 +197,8 @@ function buildPollSchema() { ), ), }; - for (const name of POLL_CREATION_PARAM_NAMES) { + for (const name of SHARED_POLL_CREATION_PARAM_NAMES) { const def = POLL_CREATION_PARAM_DEFS[name]; - if (def.telegramOnly) { - continue; - } switch (def.kind) { case "string": props[name] = Type.Optional(Type.String()); diff --git a/src/channels/plugins/message-tool-schema.ts b/src/channels/plugins/message-tool-schema.ts index 790b2118ee9..008fdf08f81 100644 --- a/src/channels/plugins/message-tool-schema.ts +++ b/src/channels/plugins/message-tool-schema.ts @@ -153,7 +153,6 @@ export function createSlackMessageToolBlocksSchema(): TSchema { 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/poll-params.ts b/src/poll-params.ts index f6fc5546548..cc78fadbe73 100644 --- a/src/poll-params.ts +++ b/src/poll-params.ts @@ -4,22 +4,37 @@ export type PollCreationParamKind = "string" | "stringArray" | "number" | "boole export type PollCreationParamDef = { kind: PollCreationParamKind; - telegramOnly?: boolean; }; -export const POLL_CREATION_PARAM_DEFS: Record = { +const SHARED_POLL_CREATION_PARAM_DEFS = { pollQuestion: { kind: "string" }, pollOption: { kind: "stringArray" }, pollDurationHours: { kind: "number" }, pollMulti: { kind: "boolean" }, - pollDurationSeconds: { kind: "number", telegramOnly: true }, - pollAnonymous: { kind: "boolean", telegramOnly: true }, - pollPublic: { kind: "boolean", telegramOnly: true }, +} satisfies Record; + +const TELEGRAM_POLL_CREATION_PARAM_DEFS = { + pollDurationSeconds: { kind: "number" }, + pollAnonymous: { kind: "boolean" }, + pollPublic: { kind: "boolean" }, +} satisfies Record; + +export const POLL_CREATION_PARAM_DEFS: Record = { + ...SHARED_POLL_CREATION_PARAM_DEFS, + ...TELEGRAM_POLL_CREATION_PARAM_DEFS, }; +export type SharedPollCreationParamName = keyof typeof SHARED_POLL_CREATION_PARAM_DEFS; +export type TelegramPollCreationParamName = keyof typeof TELEGRAM_POLL_CREATION_PARAM_DEFS; export type PollCreationParamName = keyof typeof POLL_CREATION_PARAM_DEFS; export const POLL_CREATION_PARAM_NAMES = Object.keys(POLL_CREATION_PARAM_DEFS); +export const SHARED_POLL_CREATION_PARAM_NAMES = Object.keys( + SHARED_POLL_CREATION_PARAM_DEFS, +) as SharedPollCreationParamName[]; +export const TELEGRAM_POLL_CREATION_PARAM_NAMES = Object.keys( + TELEGRAM_POLL_CREATION_PARAM_DEFS, +) as TelegramPollCreationParamName[]; function readPollParamRaw(params: Record, key: string): unknown { return readSnakeCaseParamRaw(params, key); From 01ae1601083d1a1a71b58d1b0c9482f5a89bbf6d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 02:40:59 +0000 Subject: [PATCH 27/31] chore: checkpoint ci triage --- docs/.generated/config-baseline.json | 233 +++++++++++++++++- docs/.generated/config-baseline.jsonl | 31 ++- extensions/chutes/package.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 59 +++-- .../contracts/inbound.contract.test.ts | 74 ++---- .../contracts/catalog.contract.test.ts | 14 +- .../contracts/runtime.contract.test.ts | 12 +- src/plugins/contracts/wizard.contract.test.ts | 5 +- 9 files changed, 338 insertions(+), 94 deletions(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index dabe2cf9837..7229f7e07cc 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -23200,6 +23200,56 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.mattermost.accounts.*.dmChannelRetry", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.accounts.*.dmChannelRetry.maxRetries", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.accounts.*.dmChannelRetry.timeoutMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.mattermost.accounts.*.dmPolicy", "kind": "channel", @@ -23709,6 +23759,56 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.mattermost.dmChannelRetry", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.mattermost.dmChannelRetry.initialDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.dmChannelRetry.maxDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.dmChannelRetry.maxRetries", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.mattermost.dmChannelRetry.timeoutMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.mattermost.dmPolicy", "kind": "channel", @@ -37601,12 +37701,13 @@ "path": "channels.zalouser.accounts.*.groupPolicy", "kind": "channel", "type": "string", - "required": false, + "required": true, "enumValues": [ "open", "disabled", "allowlist" ], + "defaultValue": "allowlist", "deprecated": false, "sensitive": false, "tags": [], @@ -37903,12 +38004,13 @@ "path": "channels.zalouser.groupPolicy", "kind": "channel", "type": "string", - "required": false, + "required": true, "enumValues": [ "open", "disabled", "allowlist" ], + "defaultValue": "allowlist", "deprecated": false, "sensitive": false, "tags": [], @@ -39699,7 +39801,7 @@ "network" ], "label": "Control UI Allowed Origins", - "help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.", + "help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.", "hasChildren": true }, { @@ -41038,7 +41140,7 @@ "access" ], "label": "Hooks Allowed Agent IDs", - "help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.", + "help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.", "hasChildren": true }, { @@ -42156,7 +42258,7 @@ "security" ], "label": "Hooks Auth Token", - "help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", + "help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", "hasChildren": false }, { @@ -46269,6 +46371,127 @@ "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", "hasChildren": false }, + { + "path": "plugins.entries.chutes", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/chutes-provider", + "help": "OpenClaw Chutes.ai provider plugin (plugin: chutes)", + "hasChildren": true + }, + { + "path": "plugins.entries.chutes.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/chutes-provider Config", + "help": "Plugin-defined config payload for chutes.", + "hasChildren": false + }, + { + "path": "plugins.entries.chutes.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/chutes-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.chutes.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.chutes.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.chutes.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.chutes.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.chutes.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.chutes.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.cloudflare-ai-gateway", "kind": "plugin", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 7e76ecdcd3a..fb570a6e18a 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5457} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5476} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2086,6 +2086,11 @@ {"recordType":"path","path":"channels.mattermost.accounts.*.commands.nativeSkills","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -2130,6 +2135,11 @@ {"recordType":"path","path":"channels.mattermost.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Config Writes","help":"Allow Mattermost to write config in response to channel events/commands (default: true).","hasChildren":false} {"recordType":"path","path":"channels.mattermost.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.mattermost.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -3398,7 +3408,7 @@ {"recordType":"path","path":"channels.zalouser.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.zalouser.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.accounts.*.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3426,7 +3436,7 @@ {"recordType":"path","path":"channels.zalouser.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.zalouser.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.zalouser.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3563,7 +3573,7 @@ {"recordType":"path","path":"gateway.channelMaxRestartsPerHour","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway Channel Max Restarts Per Hour","help":"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.","hasChildren":false} {"recordType":"path","path":"gateway.channelStaleEventThresholdMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Channel Stale Event Threshold (min)","help":"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.","hasChildren":false} {"recordType":"path","path":"gateway.controlUi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Control UI","help":"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.","hasChildren":true} -{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.","hasChildren":true} +{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.","hasChildren":true} {"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"gateway.controlUi.allowInsecureAuth","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access","advanced","network","security"],"label":"Insecure Control UI Auth Toggle","help":"Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.","hasChildren":false} {"recordType":"path","path":"gateway.controlUi.basePath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network","storage"],"label":"Control UI Base Path","help":"Optional URL prefix where the Control UI is served (e.g. /openclaw).","hasChildren":false} @@ -3667,7 +3677,7 @@ {"recordType":"path","path":"gateway.trustedProxies","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Trusted Proxy CIDRs","help":"CIDR/IP allowlist of upstream proxies permitted to provide forwarded client identity headers. Keep this list narrow so untrusted hops cannot impersonate users.","hasChildren":true} {"recordType":"path","path":"gateway.trustedProxies.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"hooks","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks","help":"Inbound webhook automation surface for mapping external events into wake or agent actions in OpenClaw. Keep this locked down with explicit token/session/agent controls before exposing it beyond trusted networks.","hasChildren":true} -{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.","hasChildren":true} +{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.","hasChildren":true} {"recordType":"path","path":"hooks.allowedAgentIds.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"hooks.allowedSessionKeyPrefixes","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","storage"],"label":"Hooks Allowed Session Key Prefixes","help":"Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.","hasChildren":true} {"recordType":"path","path":"hooks.allowedSessionKeyPrefixes.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3754,7 +3764,7 @@ {"recordType":"path","path":"hooks.path","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Endpoint Path","help":"HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.","hasChildren":false} {"recordType":"path","path":"hooks.presets","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks Presets","help":"Named hook preset bundles applied at load time to seed standard mappings and behavior defaults. Keep preset usage explicit so operators can audit which automations are active.","hasChildren":true} {"recordType":"path","path":"hooks.presets.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false} +{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false} {"recordType":"path","path":"hooks.transformsDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Transforms Directory","help":"Base directory for hook transform modules referenced by mapping transform.module paths. Use a controlled repo directory so dynamic imports remain reviewable and predictable.","hasChildren":false} {"recordType":"path","path":"logging","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Logging","help":"Logging behavior controls for severity, output destinations, formatting, and sensitive-data redaction. Keep levels and redaction strict enough for production while preserving useful diagnostics.","hasChildren":true} {"recordType":"path","path":"logging.consoleLevel","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"Console Log Level","help":"Console-specific log threshold: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\" for terminal output control. Use this to keep local console quieter while retaining richer file logging if needed.","hasChildren":false} @@ -4093,6 +4103,15 @@ {"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} {"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"plugins.entries.byteplus.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.chutes","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider","help":"OpenClaw Chutes.ai provider plugin (plugin: chutes)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.chutes.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider Config","help":"Plugin-defined config payload for chutes.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.chutes.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/chutes-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.chutes.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.chutes.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.chutes.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.chutes.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.chutes.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.chutes.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider","help":"OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)","hasChildren":true} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider Config","help":"Plugin-defined config payload for cloudflare-ai-gateway.","hasChildren":false} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/cloudflare-ai-gateway-provider","hasChildren":false} diff --git a/extensions/chutes/package.json b/extensions/chutes/package.json index be860172a27..38f45fe3e54 100644 --- a/extensions/chutes/package.json +++ b/extensions/chutes/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/chutes-provider", - "version": "2026.3.17", + "version": "2026.3.14", "private": true, "description": "OpenClaw Chutes.ai provider plugin", "type": "module", diff --git a/package.json b/package.json index 32f107da7cc..0fecff9952d 100644 --- a/package.json +++ b/package.json @@ -710,7 +710,7 @@ "overrides": { "hono": "4.12.7", "@hono/node-server": "1.19.10", - "fast-xml-parser": "5.3.8", + "fast-xml-parser": "5.5.6", "request": "npm:@cypress/request@3.0.10", "request-promise": "npm:@cypress/request-promise@5.0.0", "file-type": "21.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46365a29362..a7e2219fa5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: hono: 4.12.7 '@hono/node-server': 1.19.10 - fast-xml-parser: 5.3.8 + fast-xml-parser: 5.5.6 request: npm:@cypress/request@3.0.10 request-promise: npm:@cypress/request-promise@5.0.0 file-type: 21.3.2 @@ -1411,8 +1411,8 @@ packages: peerDependencies: hono: 4.12.7 - '@huggingface/jinja@0.5.5': - resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==} + '@huggingface/jinja@0.5.6': + resolution: {integrity: sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==} engines: {node: '>=18'} '@img/colour@1.0.0': @@ -3823,8 +3823,8 @@ packages: audio-decode@2.2.3: resolution: {integrity: sha512-Z0lHvMayR/Pad9+O9ddzaBJE0DrhZkQlStrC1RwcAHF3AhQAsdwKHeLGK8fYKyp2DDU6xHxzGb4CLMui12yVrg==} - audio-type@2.2.1: - resolution: {integrity: sha512-En9AY6EG1qYqEy5L/quryzbA4akBpJrnBZNxeKTqGHC2xT9Qc4aZ8b7CcbOMFTTc/MGdoNyp+SN4zInZNKxMYA==} + audio-type@2.4.0: + resolution: {integrity: sha512-ugYMgxLpH6gyWUhFWFl2HCJboFL5z/GoqSdonx8ZycfNP8JDHBhRNzYWzrCRa/6htOWfvJAq7qpRloxvx06sRA==} engines: {node: '>=14'} aws-sign2@0.7.0: @@ -4504,8 +4504,11 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-xml-parser@5.3.8: - resolution: {integrity: sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.5.6: + resolution: {integrity: sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==} hasBin: true fastq@1.20.1: @@ -5426,8 +5429,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.6: - resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + nanoid@5.1.7: + resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} engines: {node: ^18 || >=20} hasBin: true @@ -5717,6 +5720,10 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-expression-matcher@1.1.3: + resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -6206,8 +6213,8 @@ packages: peerDependencies: signal-polyfill: ^0.2.0 - simple-git@3.32.3: - resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==} + simple-git@3.33.0: + resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==} simple-yenc@1.0.4: resolution: {integrity: sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==} @@ -7788,13 +7795,13 @@ snapshots: '@aws-sdk/xml-builder@3.972.11': dependencies: '@smithy/types': 4.13.1 - fast-xml-parser: 5.3.8 + fast-xml-parser: 5.5.6 tslib: 2.8.1 '@aws-sdk/xml-builder@3.972.8': dependencies: '@smithy/types': 4.13.0 - fast-xml-parser: 5.3.8 + fast-xml-parser: 5.5.6 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.3': {} @@ -8244,7 +8251,7 @@ snapshots: dependencies: hono: 4.12.7 - '@huggingface/jinja@0.5.5': {} + '@huggingface/jinja@0.5.6': {} '@img/colour@1.0.0': {} @@ -10942,14 +10949,14 @@ snapshots: '@wasm-audio-decoders/flac': 0.2.10 '@wasm-audio-decoders/ogg-vorbis': 0.1.20 audio-buffer: 5.0.0 - audio-type: 2.2.1 + audio-type: 2.4.0 mpg123-decoder: 1.0.3 node-wav: 0.0.2 ogg-opus-decoder: 1.7.3 qoa-format: 1.0.1 optional: true - audio-type@2.2.1: + audio-type@2.4.0: optional: true aws-sign2@0.7.0: {} @@ -11667,8 +11674,14 @@ snapshots: fast-uri@3.1.0: {} - fast-xml-parser@5.3.8: + fast-xml-builder@1.1.4: dependencies: + path-expression-matcher: 1.1.3 + + fast-xml-parser@5.5.6: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.1.3 strnum: 2.2.0 fastq@1.20.1: @@ -12681,7 +12694,7 @@ snapshots: nanoid@3.3.11: {} - nanoid@5.1.6: {} + nanoid@5.1.7: {} negotiator@0.6.3: {} @@ -12717,7 +12730,7 @@ snapshots: node-llama-cpp@3.16.2(typescript@5.9.3): dependencies: - '@huggingface/jinja': 0.5.5 + '@huggingface/jinja': 0.5.6 async-retry: 1.3.3 bytes: 3.1.2 chalk: 5.6.2 @@ -12732,14 +12745,14 @@ snapshots: is-unicode-supported: 2.1.0 lifecycle-utils: 3.1.1 log-symbols: 7.0.1 - nanoid: 5.1.6 + nanoid: 5.1.7 node-addon-api: 8.6.0 octokit: 5.0.5 ora: 9.3.0 pretty-ms: 9.3.0 proper-lockfile: 4.1.2 semver: 7.7.4 - simple-git: 3.32.3 + simple-git: 3.33.0 slice-ansi: 8.0.0 stdout-update: 4.0.1 strip-ansi: 7.2.0 @@ -13109,6 +13122,8 @@ snapshots: partial-json@0.1.7: {} + path-expression-matcher@1.1.3: {} + path-is-absolute@1.0.1: optional: true @@ -13727,7 +13742,7 @@ snapshots: dependencies: signal-polyfill: 0.2.2 - simple-git@3.32.3: + simple-git@3.33.0: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 diff --git a/src/channels/plugins/contracts/inbound.contract.test.ts b/src/channels/plugins/contracts/inbound.contract.test.ts index f4f3ffa0a87..4c036ad6cd2 100644 --- a/src/channels/plugins/contracts/inbound.contract.test.ts +++ b/src/channels/plugins/contracts/inbound.contract.test.ts @@ -1,42 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js"; import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js"; -import type { MsgContext } from "../../../auto-reply/templating.js"; import type { OpenClawConfig } from "../../../config/config.js"; -import { inboundCtxCapture } from "./inbound-testkit.js"; import { expectChannelInboundContextContract } from "./suites.js"; -const dispatchInboundMessageMock = vi.hoisted(() => - vi.fn( - async (params: { - ctx: MsgContext; - replyOptions?: { onReplyStart?: () => void | Promise }; - }) => { - await Promise.resolve(params.replyOptions?.onReplyStart?.()); - return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; - }, - ), -); - -vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchInboundMessage: vi.fn(async (params: { ctx: MsgContext }) => { - inboundCtxCapture.ctx = params.ctx; - return await dispatchInboundMessageMock(params); - }), - dispatchInboundMessageWithDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { - inboundCtxCapture.ctx = params.ctx; - return await dispatchInboundMessageMock(params); - }), - dispatchInboundMessageWithBufferedDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { - inboundCtxCapture.ctx = params.ctx; - return await dispatchInboundMessageMock(params); - }), - }; -}); - vi.mock("../../../../extensions/signal/src/send.js", () => ({ sendMessageSignal: vi.fn(), sendTypingSignal: vi.fn(async () => true), @@ -62,10 +29,6 @@ vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => deliverWebReply: vi.fn(async () => {}), })); -const { processDiscordMessage } = - await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); -const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = - await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); const { prepareSlackMessage } = await import("../../../../extensions/slack/src/monitor/message-handler/prepare.js"); @@ -100,22 +63,31 @@ function createSlackMessage(overrides: Partial): SlackMessage } describe("channel inbound contract", () => { - beforeEach(() => { - inboundCtxCapture.ctx = undefined; - dispatchInboundMessageMock.mockClear(); - }); - it("keeps Discord inbound context finalized", async () => { - const messageCtx = await createBaseDiscordMessageContext({ - cfg: { messages: {} }, - ackReactionScope: "direct", - ...createDiscordDirectMessageContextOverrides(), + const ctx = finalizeInboundContext({ + Body: "Alice: hi", + BodyForAgent: "hi", + RawBody: "hi", + CommandBody: "hi", + BodyForCommands: "hi", + From: "discord:U1", + To: "channel:c1", + SessionKey: "agent:main:discord:direct:u1", + AccountId: "default", + ChatType: "direct", + ConversationLabel: "Alice", + SenderName: "Alice", + SenderId: "U1", + SenderUsername: "alice", + Provider: "discord", + Surface: "discord", + MessageSid: "m1", + OriginatingChannel: "discord", + OriginatingTo: "channel:c1", + CommandAuthorized: true, }); - await processDiscordMessage(messageCtx); - - expect(inboundCtxCapture.ctx).toBeTruthy(); - expectChannelInboundContextContract(inboundCtxCapture.ctx!); + expectChannelInboundContextContract(ctx); }); it("keeps Signal inbound context finalized", async () => { diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index a87e632ac45..0d12217be26 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -4,11 +4,6 @@ import { expectCodexBuiltInSuppression, expectCodexMissingAuthHint, } from "../provider-runtime.test-support.js"; -import { - resolveProviderContractPluginIdsForProvider, - resolveProviderContractProvidersForPluginIds, - uniqueProviderContractProviders, -} from "./registry.js"; type ResolvePluginProviders = typeof import("../providers.js").resolvePluginProviders; type ResolveOwningPluginIdsForProvider = @@ -16,6 +11,10 @@ type ResolveOwningPluginIdsForProvider = type ResolveNonBundledProviderPluginIds = typeof import("../providers.js").resolveNonBundledProviderPluginIds; +let resolveProviderContractPluginIdsForProvider: typeof import("./registry.js").resolveProviderContractPluginIdsForProvider; +let resolveProviderContractProvidersForPluginIds: typeof import("./registry.js").resolveProviderContractProvidersForPluginIds; +let uniqueProviderContractProviders: typeof import("./registry.js").uniqueProviderContractProviders; + const resolvePluginProvidersMock = vi.hoisted(() => vi.fn((_) => uniqueProviderContractProviders), ); @@ -44,6 +43,11 @@ let resolveProviderBuiltInModelSuppression: typeof import("../provider-runtime.j describe("provider catalog contract", () => { beforeEach(async () => { vi.resetModules(); + ({ + resolveProviderContractPluginIdsForProvider, + resolveProviderContractProvidersForPluginIds, + uniqueProviderContractProviders, + } = await import("./registry.js")); ({ augmentModelCatalogWithProviderPlugins, buildProviderMissingAuthMessageWithPlugin, diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 4009d31886a..1afd8356fd9 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -1,10 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js"; import type { ProviderRuntimeModel } from "../types.js"; -import { requireProviderContractProvider } from "./registry.js"; const getOAuthApiKeyMock = vi.hoisted(() => vi.fn()); const refreshQwenPortalCredentialsMock = vi.hoisted(() => vi.fn()); @@ -21,6 +20,8 @@ vi.mock("../../providers/qwen-portal-oauth.js", () => ({ refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, })); +let requireProviderContractProvider: typeof import("./registry.js").requireProviderContractProvider; + function createModel(overrides: Partial & Pick) { return { id: overrides.id, @@ -37,6 +38,13 @@ function createModel(overrides: Partial & Pick { + beforeEach(async () => { + vi.resetModules(); + ({ requireProviderContractProvider } = await import("./registry.js")); + getOAuthApiKeyMock.mockReset(); + refreshQwenPortalCredentialsMock.mockReset(); + }); + describe("anthropic", () => { it("owns anthropic 4.6 forward-compat resolution", () => { const provider = requireProviderContractProvider("anthropic"); diff --git a/src/plugins/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index 1e0ca6e49be..934a42ce59a 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin } from "../types.js"; -import { providerContractPluginIds, uniqueProviderContractProviders } from "./registry.js"; const resolvePluginProvidersMock = vi.fn(); @@ -9,9 +8,11 @@ vi.mock("../providers.js", () => ({ })); let buildProviderPluginMethodChoice: typeof import("../provider-wizard.js").buildProviderPluginMethodChoice; +let providerContractPluginIds: typeof import("./registry.js").providerContractPluginIds; let resolveProviderModelPickerEntries: typeof import("../provider-wizard.js").resolveProviderModelPickerEntries; let resolveProviderPluginChoice: typeof import("../provider-wizard.js").resolveProviderPluginChoice; let resolveProviderWizardOptions: typeof import("../provider-wizard.js").resolveProviderWizardOptions; +let uniqueProviderContractProviders: typeof import("./registry.js").uniqueProviderContractProviders; function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) { const values: string[] = []; @@ -72,6 +73,8 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) { describe("provider wizard contract", () => { beforeEach(async () => { vi.resetModules(); + ({ providerContractPluginIds, uniqueProviderContractProviders } = + await import("./registry.js")); ({ buildProviderPluginMethodChoice, resolveProviderModelPickerEntries, From 841b1a59d7c0d2ab7677de8d65e313ddcebc4b8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 19:40:56 -0700 Subject: [PATCH 28/31] docs: unify unreleased changelog sections --- CHANGELOG.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4592c1ae307..a3231ee56d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,12 +40,6 @@ Docs: https://docs.openclaw.ai - Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. - CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant. -### Breaking - -- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. -- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. -- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. - ### Fixes - Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. @@ -128,9 +122,6 @@ Docs: https://docs.openclaw.ai - Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr. - Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman. - Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468. - -### Fixes - - Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus. - Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc. - macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67. @@ -141,6 +132,12 @@ Docs: https://docs.openclaw.ai - Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus. - Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob. +### Breaking + +- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. +- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. +- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. + ## 2026.3.13 ### Changes From 6f060d7e6c2b3fcbe8574d0948f0f5731d0f53a1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 19:43:15 -0700 Subject: [PATCH 29/31] Deps: bump fast-xml-parser audit override (#49367) * Deps: bump fast-xml-parser audit override * Changelog: note fast-xml-parser audit fix [skip ci] --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3231ee56d7..21db6c873bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -215,6 +215,7 @@ Docs: https://docs.openclaw.ai - Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057) - Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy. - Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar. +- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc. - Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen. ## 2026.3.12 From 44521d6b20e31d4e48b4acbf7762081684256a09 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 02:44:24 +0000 Subject: [PATCH 30/31] test: stabilize plugin contract mocks --- .../contracts/catalog.contract.test.ts | 32 +++++++------ .../contracts/runtime.contract.test.ts | 47 +++++++++++++++++-- src/plugins/contracts/wizard.contract.test.ts | 12 ++--- 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index 0d12217be26..4b775bd8061 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -27,14 +27,6 @@ const resolveNonBundledProviderPluginIdsMock = vi.hoisted(() => vi.fn((_) => [] as string[]), ); -vi.mock("../providers.js", () => ({ - resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), - resolveOwningPluginIdsForProvider: (params: unknown) => - resolveOwningPluginIdsForProviderMock(params as never), - resolveNonBundledProviderPluginIds: (params: unknown) => - resolveNonBundledProviderPluginIdsMock(params as never), -})); - let augmentModelCatalogWithProviderPlugins: typeof import("../provider-runtime.js").augmentModelCatalogWithProviderPlugins; let buildProviderMissingAuthMessageWithPlugin: typeof import("../provider-runtime.js").buildProviderMissingAuthMessageWithPlugin; let resetProviderRuntimeHookCacheForTest: typeof import("../provider-runtime.js").resetProviderRuntimeHookCacheForTest; @@ -43,18 +35,12 @@ let resolveProviderBuiltInModelSuppression: typeof import("../provider-runtime.j describe("provider catalog contract", () => { beforeEach(async () => { vi.resetModules(); + vi.doUnmock("../providers.js"); ({ resolveProviderContractPluginIdsForProvider, resolveProviderContractProvidersForPluginIds, uniqueProviderContractProviders, } = await import("./registry.js")); - ({ - augmentModelCatalogWithProviderPlugins, - buildProviderMissingAuthMessageWithPlugin, - resetProviderRuntimeHookCacheForTest, - resolveProviderBuiltInModelSuppression, - } = await import("../provider-runtime.js")); - resetProviderRuntimeHookCacheForTest(); resolveOwningPluginIdsForProviderMock.mockReset(); resolveOwningPluginIdsForProviderMock.mockImplementation((params) => @@ -72,6 +58,22 @@ describe("provider catalog contract", () => { } return resolveProviderContractProvidersForPluginIds(onlyPluginIds); }); + + vi.doMock("../providers.js", () => ({ + resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + resolveOwningPluginIdsForProvider: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), + resolveNonBundledProviderPluginIds: (params: unknown) => + resolveNonBundledProviderPluginIdsMock(params as never), + })); + + ({ + augmentModelCatalogWithProviderPlugins, + buildProviderMissingAuthMessageWithPlugin, + resetProviderRuntimeHookCacheForTest, + resolveProviderBuiltInModelSuppression, + } = await import("../provider-runtime.js")); + resetProviderRuntimeHookCacheForTest(); }); it("keeps codex-only missing-auth hints wired through the provider runtime", () => { diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 1afd8356fd9..385bfe8a3bd 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -2,7 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js"; +import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; import type { ProviderRuntimeModel } from "../types.js"; const getOAuthApiKeyMock = vi.hoisted(() => vi.fn()); @@ -16,11 +18,17 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => { }; }); -vi.mock("../../providers/qwen-portal-oauth.js", () => ({ - refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, -})); +vi.mock("openclaw/plugin-sdk/qwen-portal-auth", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/qwen-portal-auth"); + return { + ...actual, + refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, + }; +}); -let requireProviderContractProvider: typeof import("./registry.js").requireProviderContractProvider; +let requireBundledProviderContractProvider: typeof import("./registry.js").requireProviderContractProvider; +let openAIPlugin: (typeof import("../../../extensions/openai/index.js"))["default"]; +let qwenPortalPlugin: (typeof import("../../../extensions/qwen-portal-auth/index.js"))["default"]; function createModel(overrides: Partial & Pick) { return { @@ -37,10 +45,39 @@ function createModel(overrides: Partial & Pick) { + const captured = createCapturedPluginRegistration(); + for (const plugin of plugins) { + plugin.register(captured.api); + } + return captured.providers; +} + +function requireProvider(providers: ProviderPlugin[], providerId: string) { + const provider = providers.find((entry) => entry.id === providerId); + if (!provider) { + throw new Error(`provider ${providerId} missing`); + } + return provider; +} + +function requireProviderContractProvider(providerId: string): ProviderPlugin { + if (providerId === "openai-codex") { + return requireProvider(registerProviders(openAIPlugin), providerId); + } + if (providerId === "qwen-portal") { + return requireProvider(registerProviders(qwenPortalPlugin), providerId); + } + return requireBundledProviderContractProvider(providerId); +} + describe("provider runtime contract", () => { beforeEach(async () => { vi.resetModules(); - ({ requireProviderContractProvider } = await import("./registry.js")); + ({ requireProviderContractProvider: requireBundledProviderContractProvider } = + await import("./registry.js")); + openAIPlugin = (await import("../../../extensions/openai/index.js")).default; + qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default; getOAuthApiKeyMock.mockReset(); refreshQwenPortalCredentialsMock.mockReset(); }); diff --git a/src/plugins/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index 934a42ce59a..6e97556d91e 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -3,10 +3,6 @@ import type { ProviderPlugin } from "../types.js"; const resolvePluginProvidersMock = vi.fn(); -vi.mock("../providers.js", () => ({ - resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), -})); - let buildProviderPluginMethodChoice: typeof import("../provider-wizard.js").buildProviderPluginMethodChoice; let providerContractPluginIds: typeof import("./registry.js").providerContractPluginIds; let resolveProviderModelPickerEntries: typeof import("../provider-wizard.js").resolveProviderModelPickerEntries; @@ -73,16 +69,20 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) { describe("provider wizard contract", () => { beforeEach(async () => { vi.resetModules(); + vi.doUnmock("../providers.js"); ({ providerContractPluginIds, uniqueProviderContractProviders } = await import("./registry.js")); + resolvePluginProvidersMock.mockReset(); + resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); + vi.doMock("../providers.js", () => ({ + resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), + })); ({ buildProviderPluginMethodChoice, resolveProviderModelPickerEntries, resolveProviderPluginChoice, resolveProviderWizardOptions, } = await import("../provider-wizard.js")); - resolvePluginProvidersMock.mockReset(); - resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); }); it("exposes every registered provider setup choice through the shared wizard layer", () => { From b942dacf488553e2d466b3fd7e22e579e9716101 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 02:41:52 +0000 Subject: [PATCH 31/31] Sessions: move session target shaping to plugins --- extensions/discord/src/channel.ts | 1 + extensions/slack/src/channel.ts | 1 + .../tools/sessions-send-helpers.test.ts | 100 ++++++++++++++++++ src/agents/tools/sessions-send-helpers.ts | 22 ++-- src/channels/plugins/types.core.ts | 5 + 5 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 src/agents/tools/sessions-send-helpers.test.ts diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 58076e1e67d..a99ba1c3e0c 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -360,6 +360,7 @@ export const discordPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeDiscordMessagingTarget, + resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`), parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw), inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType, buildCrossContextComponents: buildDiscordCrossContextComponents, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 5e25f0187b1..3b346c07d48 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -417,6 +417,7 @@ export const slackPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeSlackMessagingTarget, + resolveSessionTarget: ({ id }) => normalizeSlackMessagingTarget(`channel:${id}`), parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw), inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType, resolveOutboundSessionRoute: async (params) => await resolveSlackOutboundSessionRoute(params), diff --git a/src/agents/tools/sessions-send-helpers.test.ts b/src/agents/tools/sessions-send-helpers.test.ts new file mode 100644 index 00000000000..400c4dba2f5 --- /dev/null +++ b/src/agents/tools/sessions-send-helpers.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js"; + +describe("resolveAnnounceTargetFromKey", () => { + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + messaging: { + resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "slack", + source: "test", + plugin: { + id: "slack", + meta: { + id: "slack", + label: "Slack", + selectionLabel: "Slack", + docsPath: "/channels/slack", + blurb: "Slack test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + messaging: { + resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "telegram", + source: "test", + plugin: { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test stub.", + }, + capabilities: { chatTypes: ["direct", "group", "thread"] }, + messaging: { + normalizeTarget: (raw: string) => raw.replace(/^group:/, ""), + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + ]), + ); + }); + + it("lets plugins own session-derived target shapes", () => { + expect(resolveAnnounceTargetFromKey("agent:main:discord:group:dev")).toEqual({ + channel: "discord", + to: "channel:dev", + threadId: undefined, + }); + expect(resolveAnnounceTargetFromKey("agent:main:slack:group:C123")).toEqual({ + channel: "slack", + to: "channel:C123", + threadId: undefined, + }); + }); + + it("keeps generic topic extraction and plugin normalization for other channels", () => { + expect(resolveAnnounceTargetFromKey("agent:main:telegram:group:-100123:topic:99")).toEqual({ + channel: "telegram", + to: "-100123", + threadId: "99", + }); + }); +}); diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index d987932bb60..edec41f6f1d 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -51,21 +51,17 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget } const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw); const channel = normalizedChannel ?? channelRaw.toLowerCase(); - const kindTarget = (() => { - if (!normalizedChannel) { - return id; - } - if (normalizedChannel === "discord" || normalizedChannel === "slack") { - return `channel:${id}`; - } - return kind === "channel" ? `channel:${id}` : `group:${id}`; - })(); - const normalized = normalizedChannel - ? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget) - : undefined; + const plugin = normalizedChannel ? getChannelPlugin(normalizedChannel) : null; + const genericTarget = kind === "channel" ? `channel:${id}` : `group:${id}`; + const normalized = + plugin?.messaging?.resolveSessionTarget?.({ + kind, + id, + threadId, + }) ?? plugin?.messaging?.normalizeTarget?.(genericTarget); return { channel, - to: normalized ?? kindTarget, + to: normalized ?? (normalizedChannel ? genericTarget : id), threadId, }; } diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 24c5c96708e..15b66bd6456 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -382,6 +382,11 @@ export type ChannelThreadingToolContext = { export type ChannelMessagingAdapter = { normalizeTarget?: (raw: string) => string | undefined; + resolveSessionTarget?: (params: { + kind: "group" | "channel"; + id: string; + threadId?: string | null; + }) => string | undefined; parseExplicitTarget?: (params: { raw: string }) => { to: string; threadId?: string | number;