refactor(agents): unify subagent announce delivery pipeline

Co-authored-by: Smith Labs <SmithLabsLLC@users.noreply.github.com>
Co-authored-by: Do Cao Hieu <docaohieu2808@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-02-26 00:30:19 +00:00
parent aedf62ac7e
commit 4258a3307f
14 changed files with 623 additions and 132 deletions

View File

@@ -0,0 +1,73 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
import { getActivePluginRegistry, getActivePluginRegistryKey } from "../../plugins/runtime.js";
import {
isDeliverableMessageChannel,
normalizeMessageChannel,
type DeliverableMessageChannel,
} from "../../utils/message-channel.js";
const bootstrapAttempts = new Set<string>();
export function normalizeDeliverableOutboundChannel(
raw?: string | null,
): DeliverableMessageChannel | undefined {
const normalized = normalizeMessageChannel(raw);
if (!normalized || !isDeliverableMessageChannel(normalized)) {
return undefined;
}
return normalized;
}
function maybeBootstrapChannelPlugin(params: {
channel: DeliverableMessageChannel;
cfg?: OpenClawConfig;
}): void {
const cfg = params.cfg;
if (!cfg) {
return;
}
const activeRegistry = getActivePluginRegistry();
if ((activeRegistry?.channels?.length ?? 0) > 0) {
return;
}
const registryKey = getActivePluginRegistryKey() ?? "<none>";
const attemptKey = `${registryKey}:${params.channel}`;
if (bootstrapAttempts.has(attemptKey)) {
return;
}
bootstrapAttempts.add(attemptKey);
const autoEnabled = applyPluginAutoEnable({ config: cfg }).config;
const defaultAgentId = resolveDefaultAgentId(autoEnabled);
const workspaceDir = resolveAgentWorkspaceDir(autoEnabled, defaultAgentId);
loadOpenClawPlugins({
config: autoEnabled,
workspaceDir,
});
}
export function resolveOutboundChannelPlugin(params: {
channel: string;
cfg?: OpenClawConfig;
}): ChannelPlugin | undefined {
const normalized = normalizeDeliverableOutboundChannel(params.channel);
if (!normalized) {
return undefined;
}
const resolve = () => getChannelPlugin(normalized);
const current = resolve();
if (current) {
return current;
}
maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg });
return resolve();
}

View File

@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
getChannelPlugin: vi.fn(),
resolveOutboundTarget: vi.fn(),
deliverOutboundPayloads: vi.fn(),
loadOpenClawPlugins: vi.fn(),
}));
vi.mock("../../channels/plugins/index.js", () => ({
@@ -11,6 +12,19 @@ vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: mocks.getChannelPlugin,
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveDefaultAgentId: () => "main",
resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
}));
vi.mock("../../plugins/loader.js", () => ({
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
}));
vi.mock("./targets.js", () => ({
resolveOutboundTarget: mocks.resolveOutboundTarget,
}));
@@ -19,13 +33,17 @@ vi.mock("./deliver.js", () => ({
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
}));
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { sendMessage } from "./message.js";
describe("sendMessage", () => {
beforeEach(() => {
setActivePluginRegistry(createTestRegistry([]));
mocks.getChannelPlugin.mockClear();
mocks.resolveOutboundTarget.mockClear();
mocks.deliverOutboundPayloads.mockClear();
mocks.loadOpenClawPlugins.mockClear();
mocks.getChannelPlugin.mockReturnValue({
outbound: { deliveryMode: "direct" },
@@ -37,8 +55,8 @@ describe("sendMessage", () => {
it("passes explicit agentId to outbound delivery for scoped media roots", async () => {
await sendMessage({
cfg: {},
channel: "mattermost",
to: "channel:town-square",
channel: "telegram",
to: "123456",
content: "hi",
agentId: "work",
});
@@ -46,9 +64,34 @@ describe("sendMessage", () => {
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "work",
channel: "mattermost",
to: "channel:town-square",
channel: "telegram",
to: "123456",
}),
);
});
it("recovers telegram plugin resolution so message/send does not fail with Unknown channel: telegram", async () => {
const telegramPlugin = {
outbound: { deliveryMode: "direct" },
};
mocks.getChannelPlugin
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(telegramPlugin)
.mockReturnValue(telegramPlugin);
await expect(
sendMessage({
cfg: { channels: { telegram: { botToken: "test-token" } } },
channel: "telegram",
to: "123456",
content: "hi",
}),
).resolves.toMatchObject({
channel: "telegram",
to: "123456",
via: "direct",
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,4 +1,3 @@
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js";
@@ -10,6 +9,10 @@ import {
type GatewayClientMode,
type GatewayClientName,
} from "../../utils/message-channel.js";
import {
normalizeDeliverableOutboundChannel,
resolveOutboundChannelPlugin,
} from "./channel-resolution.js";
import { resolveMessageChannelSelection } from "./channel-selection.js";
import {
deliverOutboundPayloads,
@@ -107,17 +110,18 @@ async function resolveRequiredChannel(params: {
cfg: OpenClawConfig;
channel?: string;
}): Promise<string> {
const channel = params.channel?.trim()
? normalizeChannelId(params.channel)
: (await resolveMessageChannelSelection({ cfg: params.cfg })).channel;
if (!channel) {
throw new Error(`Unknown channel: ${params.channel}`);
if (params.channel?.trim()) {
const normalized = normalizeDeliverableOutboundChannel(params.channel);
if (!normalized) {
throw new Error(`Unknown channel: ${params.channel}`);
}
return normalized;
}
return channel;
return (await resolveMessageChannelSelection({ cfg: params.cfg })).channel;
}
function resolveRequiredPlugin(channel: string) {
const plugin = getChannelPlugin(channel);
function resolveRequiredPlugin(channel: string, cfg: OpenClawConfig) {
const plugin = resolveOutboundChannelPlugin({ channel, cfg });
if (!plugin) {
throw new Error(`Unknown channel: ${channel}`);
}
@@ -166,7 +170,7 @@ async function callMessageGateway<T>(params: {
export async function sendMessage(params: MessageSendParams): Promise<MessageSendResult> {
const cfg = params.cfg ?? loadConfig();
const channel = await resolveRequiredChannel({ cfg, channel: params.channel });
const plugin = resolveRequiredPlugin(channel);
const plugin = resolveRequiredPlugin(channel, cfg);
const deliveryMode = plugin.outbound?.deliveryMode ?? "direct";
const normalizedPayloads = normalizeReplyPayloadsForDelivery([
{
@@ -279,7 +283,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
durationSeconds: params.durationSeconds,
durationHours: params.durationHours,
};
const plugin = resolveRequiredPlugin(channel);
const plugin = resolveRequiredPlugin(channel, cfg);
const outbound = plugin?.outbound;
if (!outbound?.sendPoll) {
throw new Error(`Unsupported poll channel: ${channel}`);

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getChannelPlugin: vi.fn(),
loadOpenClawPlugins: vi.fn(),
}));
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: mocks.getChannelPlugin,
normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined,
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveDefaultAgentId: () => "main",
resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
}));
vi.mock("../../plugins/loader.js", () => ({
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
}));
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { resolveOutboundTarget } from "./targets.js";
describe("resolveOutboundTarget channel resolution", () => {
beforeEach(() => {
setActivePluginRegistry(createTestRegistry([]));
mocks.getChannelPlugin.mockReset();
mocks.loadOpenClawPlugins.mockReset();
});
it("recovers telegram plugin resolution so announce delivery does not fail with Unsupported channel: telegram", () => {
const telegramPlugin = {
id: "telegram",
meta: { label: "Telegram" },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
};
mocks.getChannelPlugin
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(telegramPlugin)
.mockReturnValue(telegramPlugin);
const result = resolveOutboundTarget({
channel: "telegram",
to: "123456",
cfg: { channels: { telegram: { botToken: "test-token" } } },
mode: "explicit",
});
expect(result).toEqual({ ok: true, to: "123456" });
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,4 @@
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
import { formatCliCommand } from "../../cli/command-format.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -20,6 +19,10 @@ import {
normalizeMessageChannel,
} from "../../utils/message-channel.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
import {
normalizeDeliverableOutboundChannel,
resolveOutboundChannelPlugin,
} from "./channel-resolution.js";
import { missingTargetError } from "./target-errors.js";
export type OutboundChannel = DeliverableMessageChannel | "none";
@@ -181,7 +184,10 @@ export function resolveOutboundTarget(params: {
};
}
const plugin = getChannelPlugin(params.channel);
const plugin = resolveOutboundChannelPlugin({
channel: params.channel,
cfg: params.cfg,
});
if (!plugin) {
return {
ok: false,
@@ -242,7 +248,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
if (rawTarget === "none" || rawTarget === "last") {
target = rawTarget;
} else if (typeof rawTarget === "string") {
const normalized = normalizeChannelId(rawTarget);
const normalized = normalizeDeliverableOutboundChannel(rawTarget);
if (normalized) {
target = normalized;
}
@@ -269,7 +275,10 @@ export function resolveHeartbeatDeliveryTarget(params: {
let effectiveAccountId = heartbeatAccountId || resolvedTarget.accountId;
if (heartbeatAccountId && resolvedTarget.channel) {
const plugin = getChannelPlugin(resolvedTarget.channel);
const plugin = resolveOutboundChannelPlugin({
channel: resolvedTarget.channel,
cfg,
});
const listAccountIds = plugin?.config.listAccountIds;
const accountIds = listAccountIds ? listAccountIds(cfg) : [];
if (accountIds.length > 0) {
@@ -331,7 +340,10 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
let reason: string | undefined;
const plugin = getChannelPlugin(resolvedTarget.channel);
const plugin = resolveOutboundChannelPlugin({
channel: resolvedTarget.channel,
cfg,
});
if (plugin?.config.resolveAllowFrom) {
const explicit = resolveOutboundTarget({
channel: resolvedTarget.channel,
@@ -516,7 +528,10 @@ export function resolveHeartbeatSenderContext(params: {
params.delivery.accountId ??
(provider === params.delivery.lastChannel ? params.delivery.lastAccountId : undefined);
const allowFromRaw = provider
? (getChannelPlugin(provider)?.config.resolveAllowFrom?.({
? (resolveOutboundChannelPlugin({
channel: provider,
cfg: params.cfg,
})?.config.resolveAllowFrom?.({
cfg: params.cfg,
accountId,
}) ?? [])