fix(heartbeat): bootstrap plugin session targets (#93630)

* fix(heartbeat): bootstrap plugin session targets

* fix(heartbeat): reuse bootstrapped route plugin

* fix(heartbeat): preserve active external route plugins

* fix(heartbeat): carry prepared plugin through routing

* fix(heartbeat): canonicalize with prepared route plugin

* fix(heartbeat): preserve explicit route account context

* fix(heartbeat): enforce prepared plugin route policy

* fix(outbound): partition prepared plugin target cache

* test(cron): expect prepared delivery plugin

* test(outbound): use complete plugin fixtures

* fix(outbound): bootstrap direct metadata shells

* fix(outbound): scope external runtime activation

* test(outbound): model activated direct plugins

* fix(heartbeat): keep route policy activation-aware

* fix(heartbeat): preserve prepared plugin policy

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
ZengWen-DT
2026-06-17 01:02:49 +08:00
committed by GitHub
co-authored by Vincent Koc
parent 617f97d4b9
commit 6470bb7625
14 changed files with 856 additions and 111 deletions
@@ -450,6 +450,8 @@ describe("resolveDeliveryTarget", () => {
channel: "forum",
input: "123456789",
accountId: undefined,
plugin: expect.objectContaining({ id: "forum" }),
preferredKind: undefined,
});
});
@@ -923,6 +925,8 @@ describe("resolveDeliveryTarget", () => {
channel: "forum",
input: "123456789",
accountId: undefined,
plugin: expect.objectContaining({ id: "forum" }),
preferredKind: undefined,
});
});
@@ -44,6 +44,7 @@ vi.mock("../../plugins/runtime.js", () => ({
}));
vi.mock("../../utils/message-channel.js", () => ({
INTERNAL_MESSAGE_CHANNEL: "webchat",
normalizeMessageChannel: (...args: unknown[]) => normalizeMessageChannelMock(...args),
isDeliverableMessageChannel: (...args: unknown[]) => isDeliverableMessageChannelMock(...args),
}));
@@ -231,6 +232,86 @@ describe("outbound channel resolution", () => {
});
});
it("bootstraps an external channel before strict deliverability validation", async () => {
const plugin = { id: "external-channel", outbound: { sendText: vi.fn() } };
isDeliverableMessageChannelMock.mockImplementation(
(value?: string) =>
value === "external-channel" && resolveRuntimePluginRegistryMock.mock.calls.length > 0,
);
getLoadedChannelPluginMock.mockImplementation(() =>
resolveRuntimePluginRegistryMock.mock.calls.length > 0 ? plugin : undefined,
);
const channelResolution = await importChannelResolution("bootstrap-external-channel");
expect(
channelResolution.resolveOutboundChannelPlugin({
channel: "external-channel",
cfg: { channels: {} } as never,
allowBootstrap: true,
}),
).toBe(plugin);
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
});
it("resolves a bootstrapped external channel from the active registry when the pin is stale", async () => {
const plugin = { id: "external-channel", outbound: { sendText: vi.fn() } };
isDeliverableMessageChannelMock.mockReturnValue(false);
getLoadedChannelPluginMock.mockReturnValue(undefined);
getChannelPluginMock.mockReturnValue(undefined);
getActivePluginChannelRegistryMock.mockReturnValue({
channels: [{ plugin: { id: "other-channel" } }],
});
getActivePluginRegistryMock.mockReturnValue({ channels: [{ plugin }] });
const channelResolution = await importChannelResolution("bootstrap-external-active-registry");
expect(
channelResolution.resolveOutboundChannelPlugin({
channel: "external-channel",
cfg: { channels: {} } as never,
allowBootstrap: true,
}),
).toBe(plugin);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
});
it("keeps a bootstrapped external alias available to normal runtime lookups", async () => {
const message = { send: { text: vi.fn() } };
const plugin = {
id: "external-channel",
meta: { aliases: ["external"] },
message,
};
isDeliverableMessageChannelMock.mockReturnValue(false);
getLoadedChannelPluginMock.mockReturnValue(undefined);
getChannelPluginMock.mockReturnValue(undefined);
getActivePluginChannelRegistryMock.mockReturnValue({ channels: [] });
getActivePluginRegistryMock.mockImplementation(() =>
resolveRuntimePluginRegistryMock.mock.calls.length > 0 ? { channels: [{ plugin }] } : null,
);
const channelResolution = await importChannelResolution("bootstrap-external-alias");
expect(
channelResolution.resolveOutboundChannelPlugin({
channel: "external",
cfg: { channels: {} } as never,
allowBootstrap: true,
}),
).toBe(plugin);
expect(
channelResolution.resolveOutboundChannelPlugin({
channel: "external",
cfg: { channels: {} } as never,
}),
).toBe(plugin);
expect(
channelResolution.resolveOutboundChannelMessageAdapter({
channel: "external",
cfg: { channels: {} } as never,
}),
).toBe(message);
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
});
it("bootstraps instead of returning a pinned setup shell as the outbound plugin", async () => {
const setupPlugin = { id: "alpha" };
const runtimePlugin = { id: "alpha", outbound: { sendText: vi.fn() } };
@@ -252,6 +333,31 @@ describe("outbound channel resolution", () => {
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
});
it("bootstraps instead of returning direct outbound metadata from a setup shell", async () => {
const setupPlugin = { id: "alpha", outbound: { deliveryMode: "direct" } };
const runtimePlugin = { id: "alpha", outbound: { deliveryMode: "direct", sendText: vi.fn() } };
getLoadedChannelPluginMock.mockReturnValue(setupPlugin);
getChannelPluginMock.mockReturnValue(undefined);
getActivePluginChannelRegistryMock.mockReturnValue({
channels: [{ plugin: setupPlugin }],
});
getActivePluginRegistryMock.mockImplementation(() =>
resolveRuntimePluginRegistryMock.mock.calls.length > 0
? { channels: [{ plugin: runtimePlugin }] }
: { channels: [{ plugin: setupPlugin }] },
);
const channelResolution = await importChannelResolution("bootstrap-outbound-metadata-shell");
expect(
channelResolution.resolveOutboundChannelPlugin({
channel: "alpha",
cfg: { channels: {} } as never,
allowBootstrap: true,
}),
).toBe(runtimePlugin);
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
});
it("does not return a setup shell when bootstrap does not produce a runtime plugin", async () => {
const setupPlugin = { id: "alpha" };
getLoadedChannelPluginMock.mockReturnValue(setupPlugin);
@@ -463,6 +569,28 @@ describe("outbound channel resolution", () => {
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
});
it("bootstraps an external channel before resolving its message adapter", async () => {
const message = { send: { text: vi.fn() } };
const plugin = { id: "external-channel", message };
isDeliverableMessageChannelMock.mockImplementation(
(value?: string) =>
value === "external-channel" && resolveRuntimePluginRegistryMock.mock.calls.length > 0,
);
getLoadedChannelPluginMock.mockImplementation(() =>
resolveRuntimePluginRegistryMock.mock.calls.length > 0 ? plugin : undefined,
);
const channelResolution = await importChannelResolution("message-adapter-external-channel");
expect(
channelResolution.resolveOutboundChannelMessageAdapter({
channel: "external-channel",
cfg: { channels: {} } as never,
allowBootstrap: true,
}),
).toBe(message);
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
});
it("does not bootstrap by default for outbound hot-path resolution", async () => {
const plugin = { id: "alpha" };
getLoadedChannelPluginMock.mockReturnValue(undefined);
+85 -10
View File
@@ -1,5 +1,6 @@
// Channel resolution exposes read-only outbound runtime facades and performs
// optional bootstrap for deliverable channels that are not loaded yet.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import type { ChannelMessageAdapterShape } from "../../channels/message/types.js";
import { getChannelPlugin, getLoadedChannelPlugin } from "../../channels/plugins/index.js";
import { channelPluginHasNativeApprovalPromptUi } from "../../channels/plugins/native-approval-prompt.js";
@@ -23,6 +24,7 @@ import type {
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { getActivePluginChannelRegistry, getActivePluginRegistry } from "../../plugins/runtime.js";
import {
INTERNAL_MESSAGE_CHANNEL,
isDeliverableMessageChannel,
normalizeMessageChannel,
type DeliverableMessageChannel,
@@ -111,6 +113,43 @@ function maybeBootstrapChannelPlugin(params: {
bootstrapOutboundChannelPlugin(params);
}
function normalizeOutboundChannelForResolution(params: {
channel: string;
cfg?: OpenClawConfig;
allowBootstrap?: boolean;
}): { channel?: DeliverableMessageChannel; didBootstrap: boolean } {
const normalized = normalizeMessageChannel(params.channel);
const deliverable = normalizeDeliverableOutboundChannel(normalized);
if (deliverable || !normalized || normalized === INTERNAL_MESSAGE_CHANNEL) {
return { channel: deliverable, didBootstrap: false };
}
const activeRuntimePlugin = resolveActivatedOutboundPluginFromRuntimeRegistries(normalized);
if (activeRuntimePlugin) {
return {
channel: activeRuntimePlugin.id as DeliverableMessageChannel,
didBootstrap: false,
};
}
if (params.allowBootstrap !== true) {
return { channel: undefined, didBootstrap: false };
}
// External channel ids remain normalized before their runtime is registered.
// Bootstrap first, then let the runtime candidate lookup confirm sendability.
maybeBootstrapChannelPlugin({
channel: normalized as DeliverableMessageChannel,
cfg: params.cfg,
});
const bootstrappedRuntimePlugin = resolveActivatedOutboundPluginFromRuntimeRegistries(normalized);
return {
// The pinned channel registry may intentionally lag the active runtime
// registry, so strict registry validation here would hide a usable plugin.
channel: (bootstrappedRuntimePlugin?.id ?? normalized) as DeliverableMessageChannel,
didBootstrap: true,
};
}
function resolveDirectFromRegistry(
registry: ReturnType<typeof getActivePluginRegistry>,
channel: string,
@@ -118,9 +157,18 @@ function resolveDirectFromRegistry(
if (!registry) {
return undefined;
}
const normalizedChannel = normalizeOptionalLowercaseString(channel);
if (!normalizedChannel) {
return undefined;
}
for (const entry of registry.channels) {
const plugin = entry?.plugin;
if (plugin?.id === channel) {
if (
normalizeOptionalLowercaseString(plugin?.id) === normalizedChannel ||
plugin?.meta?.aliases?.some(
(alias) => normalizeOptionalLowercaseString(alias) === normalizedChannel,
)
) {
return plugin;
}
}
@@ -144,24 +192,40 @@ function channelPluginHasRuntimeOutboundSurface(plugin: ChannelPlugin | undefine
return Boolean(plugin?.outbound ?? resolveSendCapableMessageAdapter(plugin));
}
function channelPluginHasActivatedOutboundSurface(plugin: ChannelPlugin | undefined): boolean {
return Boolean(
plugin?.outbound?.sendText ||
plugin?.outbound?.deliveryMode === "gateway" ||
resolveSendCapableMessageAdapter(plugin),
);
}
function resolveRuntimeOutboundPlugin(plugin: ChannelPlugin): ChannelPlugin | undefined {
return channelPluginHasRuntimeOutboundSurface(plugin) ? plugin : undefined;
}
function resolveActivatedOutboundPlugin(plugin: ChannelPlugin): ChannelPlugin | undefined {
return channelPluginHasActivatedOutboundSurface(plugin) ? plugin : undefined;
}
function resolveRuntimeOutboundPluginCandidate(params: {
loaded?: ChannelPlugin;
runtime?: ChannelPlugin;
setupFallback?: ChannelPlugin;
bundled?: ChannelPlugin;
allowSetupShell?: boolean;
requireActivatedRuntime?: boolean;
}): ChannelPlugin | undefined {
if (channelPluginHasRuntimeOutboundSurface(params.loaded)) {
const hasRuntimeSurface = params.requireActivatedRuntime
? channelPluginHasActivatedOutboundSurface
: channelPluginHasRuntimeOutboundSurface;
if (hasRuntimeSurface(params.loaded)) {
return params.loaded;
}
if (params.runtime) {
if (hasRuntimeSurface(params.runtime)) {
return params.runtime;
}
if (channelPluginHasRuntimeOutboundSurface(params.bundled)) {
if (hasRuntimeSurface(params.bundled)) {
return params.bundled;
}
if (params.allowSetupShell) {
@@ -202,6 +266,12 @@ function resolveRuntimeOutboundPluginFromRuntimeRegistries(
return resolveValueFromRuntimeRegistries(channel, resolveRuntimeOutboundPlugin);
}
function resolveActivatedOutboundPluginFromRuntimeRegistries(
channel: string,
): ChannelPlugin | undefined {
return resolveValueFromRuntimeRegistries(channel, resolveActivatedOutboundPlugin);
}
function toOutboundChannelRuntime(plugin: ChannelPlugin): OutboundChannelRuntime {
return {
id: plugin.id,
@@ -260,7 +330,7 @@ export function resolveOutboundChannelPlugin(params: {
cfg?: OpenClawConfig;
allowBootstrap?: boolean;
}): ChannelPlugin | undefined {
const normalized = normalizeDeliverableOutboundChannel(params.channel);
const { channel: normalized, didBootstrap } = normalizeOutboundChannelForResolution(params);
if (!normalized) {
return undefined;
}
@@ -268,7 +338,10 @@ export function resolveOutboundChannelPlugin(params: {
const resolveLoaded = () => getLoadedChannelPlugin(normalized);
const resolve = () => getChannelPlugin(normalized);
const current = resolveLoaded();
const runtimeCurrent = resolveRuntimeOutboundPluginFromRuntimeRegistries(normalized);
const requireActivatedRuntime = params.allowBootstrap === true;
const runtimeCurrent = requireActivatedRuntime
? resolveActivatedOutboundPluginFromRuntimeRegistries(normalized)
: resolveRuntimeOutboundPluginFromRuntimeRegistries(normalized);
const setupFallback = resolveDirectFromRuntimeRegistries(normalized);
const bundledCurrent = resolve();
const candidate = resolveRuntimeOutboundPluginCandidate({
@@ -277,21 +350,23 @@ export function resolveOutboundChannelPlugin(params: {
setupFallback,
bundled: bundledCurrent,
allowSetupShell: params.allowBootstrap !== true,
requireActivatedRuntime,
});
if (candidate) {
return candidate;
}
if (params.allowBootstrap !== true) {
if (params.allowBootstrap !== true || didBootstrap) {
return undefined;
}
maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg });
return resolveRuntimeOutboundPluginCandidate({
loaded: resolveLoaded(),
runtime: resolveRuntimeOutboundPluginFromRuntimeRegistries(normalized),
runtime: resolveActivatedOutboundPluginFromRuntimeRegistries(normalized),
setupFallback: resolveDirectFromRuntimeRegistries(normalized),
bundled: resolve(),
requireActivatedRuntime: true,
});
}
@@ -301,7 +376,7 @@ export function resolveOutboundChannelMessageAdapter(params: {
cfg?: OpenClawConfig;
allowBootstrap?: boolean;
}): ChannelMessageAdapterShape | undefined {
const normalized = normalizeDeliverableOutboundChannel(params.channel);
const { channel: normalized, didBootstrap } = normalizeOutboundChannelForResolution(params);
if (!normalized) {
return undefined;
}
@@ -309,7 +384,7 @@ export function resolveOutboundChannelMessageAdapter(params: {
resolveSendCapableMessageAdapter(getLoadedChannelPlugin(normalized)) ??
resolveValueFromRuntimeRegistries(normalized, resolveSendCapableMessageAdapter) ??
resolveSendCapableMessageAdapter(getChannelPlugin(normalized));
if (current || params.allowBootstrap !== true) {
if (current || params.allowBootstrap !== true || didBootstrap) {
return current;
}
maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg });
@@ -30,7 +30,10 @@ export const directChatConfig = {
},
} as OpenClawConfig;
export const directOutbound: ChannelOutboundAdapter = { deliveryMode: "direct" };
export const directOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
sendText: async () => ({ channel: "test", messageId: "test" }),
};
// Test plugins model token-gated workspace sends without booting real channel runtimes.
function hasChannelBotToken(channelConfig: unknown): boolean {
+2 -2
View File
@@ -145,7 +145,7 @@ describe("sendMessage", () => {
mocks.resolveRuntimePluginRegistry.mockClear();
mocks.getChannelPlugin.mockReturnValue({
outbound: { deliveryMode: "direct" },
outbound: { deliveryMode: "direct", sendText: vi.fn() },
});
mocks.resolveOutboundTarget.mockImplementation(({ to }: { to: string }) => ({ ok: true, to }));
mocks.deliverOutboundPayloads.mockResolvedValue([{ channel: "forum", messageId: "m1" }]);
@@ -460,7 +460,7 @@ describe("sendMessage", () => {
it("does not load registries while resolving outbound plugins", async () => {
const forumPlugin = {
outbound: { deliveryMode: "direct" },
outbound: { deliveryMode: "direct", sendText: vi.fn() },
};
mocks.getChannelPlugin
.mockReturnValueOnce(undefined)
@@ -1,7 +1,9 @@
// Covers outbound session-route resolution through plugin hooks and fallback
// target parsing, plus best-effort session metadata persistence.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createChannelTestPluginBase } from "../../test-utils/channel-plugins.js";
import { ensureOutboundSessionEntry, resolveOutboundSessionRoute } from "./outbound-session.js";
import { setMinimalOutboundSessionPluginRegistryForTests } from "./outbound-session.test-helpers.js";
@@ -64,6 +66,33 @@ describe("resolveOutboundSessionRoute", () => {
},
} as OpenClawConfig;
it("uses a prepared runtime plugin for session-route resolution", async () => {
const plugin = {
...createChannelTestPluginBase({ id: "external-channel" }),
messaging: {
resolveOutboundSessionRoute: ({ target }: { target: string }) => ({
sessionKey: `agent:main:external-channel:direct:${target}`,
baseSessionKey: `agent:main:external-channel:direct:${target}`,
peer: { kind: "direct" as const, id: target },
chatType: "direct" as const,
from: `external-channel:${target}`,
to: `user:${target}`,
}),
},
} satisfies ChannelPlugin;
const route = await resolveOutboundSessionRoute({
cfg: baseConfig,
channel: "external-channel",
plugin,
agentId: "main",
target: "u123",
});
expect(route?.to).toBe("user:u123");
expect(route?.chatType).toBe("direct");
});
async function expectResolvedRoute(params: {
cfg: OpenClawConfig;
channel: string;
+8 -4
View File
@@ -5,6 +5,7 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization
import type { MsgContext } from "../../auto-reply/templating.js";
import type { ChatType } from "../../channels/chat-type.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
import {
recordSessionMetaFromInbound,
@@ -31,6 +32,7 @@ export type OutboundSessionRoute = {
export type ResolveOutboundSessionRouteParams = {
cfg: OpenClawConfig;
channel: ChannelId;
plugin?: ChannelPlugin;
agentId: string;
accountId?: string | null;
target: string;
@@ -110,6 +112,7 @@ function inferPeerKindFromFallbackPrefixes(targets: readonly string[]): ChatType
function inferPeerKind(params: {
channel: ChannelId;
plugin?: ChannelPlugin;
target: string;
resolvedTarget?: ResolvedMessagingTarget;
}): ChatType {
@@ -121,7 +124,7 @@ function inferPeerKind(params: {
return "channel";
}
if (resolvedKind === "group") {
const plugin = resolveOutboundChannelPlugin(params.channel);
const plugin = params.plugin ?? resolveOutboundChannelPlugin(params.channel);
const chatTypes = plugin?.capabilities?.chatTypes ?? [];
const supportsChannel = chatTypes.includes("channel");
const supportsGroup = chatTypes.includes("group");
@@ -130,7 +133,7 @@ function inferPeerKind(params: {
}
return "group";
}
const plugin = resolveOutboundChannelPlugin(params.channel);
const plugin = params.plugin ?? resolveOutboundChannelPlugin(params.channel);
const strippedTarget = stripProviderPrefix(params.target, params.channel).trim();
const targets = uniqueStrings([params.target, strippedTarget].filter(Boolean));
return (
@@ -150,6 +153,7 @@ function resolveFallbackSession(
}
const peerKind = inferPeerKind({
channel: params.channel,
plugin: params.plugin,
target: params.target,
resolvedTarget: params.resolvedTarget,
});
@@ -190,8 +194,8 @@ export async function resolveOutboundSessionRoute(
return null;
}
const nextParams = { ...params, target };
const resolver = resolveOutboundChannelPlugin(params.channel)?.messaging
?.resolveOutboundSessionRoute;
const plugin = params.plugin ?? resolveOutboundChannelPlugin(params.channel);
const resolver = plugin?.messaging?.resolveOutboundSessionRoute;
if (resolver) {
// Channel plugins can provide richer route semantics than the generic target parser.
return await resolver(nextParams);
@@ -1,5 +1,6 @@
// Id-like target resolution gates plugin directory lookups to inputs that are
// specific enough to avoid broad name searches.
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { ChannelDirectoryEntryKind, ChannelId } from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { maybeResolvePluginMessagingTarget } from "./target-normalization.js";
@@ -20,6 +21,7 @@ export async function maybeResolveIdLikeTarget(params: {
input: string;
accountId?: string | null;
preferredKind?: ChannelDirectoryEntryKind | "channel";
plugin?: ChannelPlugin;
}): Promise<ResolvedIdLikeTarget | undefined> {
const target = await maybeResolvePluginMessagingTarget({
...params,
@@ -1,6 +1,7 @@
// Covers target input normalization, provider plugin normalizers, resolver
// caching, and id-like lookup heuristics.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../../config/config.js";
const getLoadedChannelPluginMock = vi.hoisted(() => vi.fn());
@@ -328,4 +329,27 @@ describe("buildTargetResolverSignature", () => {
expect(first).not.toBe(second);
});
it("partitions prepared runtime plugins from pinned and replacement plugin cache entries", () => {
const firstPlugin = {
messaging: {
targetResolver: {},
},
} as ChannelPlugin;
const replacementPlugin = {
messaging: {
targetResolver: {},
},
} as ChannelPlugin;
getLoadedChannelPluginMock.mockReturnValue(firstPlugin);
const pinned = buildTargetResolverSignature("workspace");
const prepared = buildTargetResolverSignature("workspace", firstPlugin);
const samePrepared = buildTargetResolverSignature("workspace", firstPlugin);
const replacementPrepared = buildTargetResolverSignature("workspace", replacementPlugin);
expect(prepared).not.toBe(pinned);
expect(samePrepared).toBe(prepared);
expect(replacementPrepared).not.toBe(prepared);
});
});
+46 -12
View File
@@ -25,6 +25,8 @@ type TargetNormalizerCacheEntry = {
};
const targetNormalizerCacheByChannelId = new Map<string, TargetNormalizerCacheEntry>();
const preparedPluginSignatureIds = new WeakMap<ChannelPlugin, number>();
let nextPreparedPluginSignatureId = 1;
function resolveChannelPluginForTargetRead(channelId: ChannelId): ChannelPlugin | undefined {
return getLoadedChannelPluginForRead(channelId) ?? getChannelPlugin(channelId);
@@ -38,7 +40,13 @@ export const testing = {
resetTargetNormalizerCacheForTests,
} as const;
function resolveTargetNormalizer(channelId: ChannelId): TargetNormalizer {
function resolveTargetNormalizer(
channelId: ChannelId,
preparedPlugin?: ChannelPlugin,
): TargetNormalizer {
if (preparedPlugin) {
return preparedPlugin.messaging?.normalizeTarget;
}
const version = getActivePluginChannelRegistryVersion();
const cached = targetNormalizerCacheByChannelId.get(channelId);
if (cached && cached.version === version) {
@@ -54,10 +62,25 @@ function resolveTargetNormalizer(channelId: ChannelId): TargetNormalizer {
return normalizer;
}
function resolvePreparedPluginSignatureId(plugin: ChannelPlugin): number {
const existing = preparedPluginSignatureIds.get(plugin);
if (existing) {
return existing;
}
const id = nextPreparedPluginSignatureId;
nextPreparedPluginSignatureId += 1;
preparedPluginSignatureIds.set(plugin, id);
return id;
}
/**
* Applies a channel plugin normalizer and falls back to trimmed input.
*/
export function normalizeTargetForProvider(provider: string, raw?: string): string | undefined {
export function normalizeTargetForProvider(
provider: string,
raw?: string,
plugin?: ChannelPlugin,
): string | undefined {
if (!raw) {
return undefined;
}
@@ -66,7 +89,7 @@ export function normalizeTargetForProvider(provider: string, raw?: string): stri
return undefined;
}
const providerId = normalizeOptionalLowercaseString(provider);
const normalizer = providerId ? resolveTargetNormalizer(providerId) : undefined;
const normalizer = providerId ? resolveTargetNormalizer(providerId, plugin) : undefined;
return normalizeOptionalString(normalizer?.(raw) ?? fallback);
}
@@ -92,6 +115,7 @@ export type ResolvedPluginMessagingTarget = {
export function resolveNormalizedTargetInput(
provider: string,
raw?: string,
plugin?: ChannelPlugin,
): { raw: string; normalized: string } | undefined {
const trimmed = normalizeChannelTargetInput(raw ?? "");
if (!trimmed) {
@@ -99,7 +123,7 @@ export function resolveNormalizedTargetInput(
}
return {
raw: trimmed,
normalized: normalizeTargetForProvider(provider, trimmed) ?? trimmed,
normalized: normalizeTargetForProvider(provider, trimmed, plugin) ?? trimmed,
};
}
@@ -110,11 +134,12 @@ export function looksLikeTargetId(params: {
channel: ChannelId;
raw: string;
normalized?: string;
plugin?: ChannelPlugin;
}): boolean {
const normalizedInput =
params.normalized ?? normalizeTargetForProvider(params.channel, params.raw);
const lookup = resolveChannelPluginForTargetRead(params.channel)?.messaging?.targetResolver
?.looksLikeId;
params.normalized ?? normalizeTargetForProvider(params.channel, params.raw, params.plugin);
const lookup = (params.plugin ?? resolveChannelPluginForTargetRead(params.channel))?.messaging
?.targetResolver?.looksLikeId;
if (lookup) {
// Plugin heuristics win so provider-specific ids do not fall through to
// generic phone/mention checks.
@@ -145,12 +170,14 @@ export async function maybeResolvePluginMessagingTarget(params: {
accountId?: string | null;
preferredKind?: TargetResolveKindLike;
requireIdLike?: boolean;
plugin?: ChannelPlugin;
}): Promise<ResolvedPluginMessagingTarget | undefined> {
const normalizedInput = resolveNormalizedTargetInput(params.channel, params.input);
const normalizedInput = resolveNormalizedTargetInput(params.channel, params.input, params.plugin);
if (!normalizedInput) {
return undefined;
}
const resolver = resolveChannelPluginForTargetRead(params.channel)?.messaging?.targetResolver;
const resolver = (params.plugin ?? resolveChannelPluginForTargetRead(params.channel))?.messaging
?.targetResolver;
if (!resolver?.resolveTarget) {
return undefined;
}
@@ -160,6 +187,7 @@ export async function maybeResolvePluginMessagingTarget(params: {
channel: params.channel,
raw: normalizedInput.raw,
normalized: normalizedInput.normalized,
plugin: params.plugin,
})
) {
return undefined;
@@ -186,14 +214,20 @@ export async function maybeResolvePluginMessagingTarget(params: {
/**
* Builds a cache signature for target-resolution behavior exposed by a channel plugin.
*/
export function buildTargetResolverSignature(channel: ChannelId): string {
const plugin = resolveChannelPluginForTargetRead(channel);
export function buildTargetResolverSignature(
channel: ChannelId,
preparedPlugin?: ChannelPlugin,
): string {
const plugin = preparedPlugin ?? resolveChannelPluginForTargetRead(channel);
const registryScope = preparedPlugin
? `prepared:${resolvePreparedPluginSignatureId(preparedPlugin)}`
: "pinned";
const resolver = plugin?.messaging?.targetResolver;
const hint = resolver?.hint ?? "";
const looksLike = resolver?.looksLikeId;
// Function source is only a cheap invalidation hint; resolver behavior still belongs to the plugin.
const source = looksLike ? looksLike.toString() : "";
return hashSignature(`${hint}|${source}`);
return hashSignature(`${registryScope}|${hint}|${source}`);
}
function hashSignature(value: string): string {
@@ -2,7 +2,9 @@
// ambiguity modes, display formatting, and plugin normalized fallbacks.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelDirectoryEntry } from "../../channels/plugins/types.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createChannelTestPluginBase } from "../../test-utils/channel-plugins.js";
type TargetResolverModule = typeof import("./target-resolver.js");
let resetDirectoryCache: TargetResolverModule["resetDirectoryCache"];
@@ -128,6 +130,47 @@ describe("resolveMessagingTarget (directory fallback)", () => {
expect(mocks.listGroupsLive).toHaveBeenCalledTimes(1);
});
it("does not reuse directory cache entries across prepared plugin runtimes", async () => {
const firstListGroups = vi
.fn()
.mockResolvedValue([
{ kind: "group", id: "first-id", name: "support" } satisfies ChannelDirectoryEntry,
]);
const replacementListGroups = vi
.fn()
.mockResolvedValue([
{ kind: "group", id: "replacement-id", name: "support" } satisfies ChannelDirectoryEntry,
]);
const firstPlugin = {
...createChannelTestPluginBase({ id: "richchat" }),
directory: { listGroups: firstListGroups },
messaging: { targetResolver: {} },
} satisfies ChannelPlugin;
const replacementPlugin = {
...createChannelTestPluginBase({ id: "richchat" }),
directory: { listGroups: replacementListGroups },
messaging: { targetResolver: {} },
} satisfies ChannelPlugin;
const first = await expectOkResolution({
cfg,
channel: "richchat",
input: "support",
plugin: firstPlugin,
});
const replacement = await expectOkResolution({
cfg,
channel: "richchat",
input: "support",
plugin: replacementPlugin,
});
expect(first.target.to).toBe("first-id");
expect(replacement.target.to).toBe("replacement-id");
expect(firstListGroups).toHaveBeenCalledOnce();
expect(replacementListGroups).toHaveBeenCalledOnce();
});
it("skips directory lookup for direct ids", async () => {
const result = await expectOkResolution({
cfg,
+39 -13
View File
@@ -2,6 +2,7 @@
// live fallback lookups, and normalized fallback targets.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type {
ChannelDirectoryEntry,
ChannelDirectoryEntryKind,
@@ -59,6 +60,7 @@ export async function resolveChannelTarget(params: {
runtime?: RuntimeEnv;
resolveAmbiguous?: ResolveAmbiguousMode;
unknownTargetMode?: "error" | "normalized";
plugin?: ChannelPlugin;
}): Promise<ResolveMessagingTargetResult> {
return resolveMessagingTarget(params);
}
@@ -157,6 +159,7 @@ function detectTargetKind(
channel: ChannelId,
raw: string,
preferred?: TargetResolveKind,
plugin?: ChannelPlugin,
): TargetResolveKind {
if (preferred) {
return preferred;
@@ -165,7 +168,9 @@ function detectTargetKind(
if (!trimmed) {
return "group";
}
const inferredChatType = getChannelPlugin(channel)?.messaging?.inferTargetChatType?.({ to: raw });
const inferredChatType = (plugin ?? getChannelPlugin(channel))?.messaging?.inferTargetChatType?.({
to: raw,
});
if (inferredChatType === "direct") {
return "user";
}
@@ -186,8 +191,12 @@ function detectTargetKind(
return "group";
}
function normalizeDirectoryEntryId(channel: ChannelId, entry: ChannelDirectoryEntry): string {
const normalized = normalizeTargetForProvider(channel, entry.id);
function normalizeDirectoryEntryId(
channel: ChannelId,
entry: ChannelDirectoryEntry,
plugin?: ChannelPlugin,
): string {
const normalized = normalizeTargetForProvider(channel, entry.id, plugin);
return normalized ?? entry.id.trim();
}
@@ -195,12 +204,15 @@ function matchesDirectoryEntry(params: {
channel: ChannelId;
entry: ChannelDirectoryEntry;
query: string;
plugin?: ChannelPlugin;
}): boolean {
const query = normalizeQuery(params.query);
if (!query) {
return false;
}
const id = stripTargetPrefixes(normalizeDirectoryEntryId(params.channel, params.entry));
const id = stripTargetPrefixes(
normalizeDirectoryEntryId(params.channel, params.entry, params.plugin),
);
const name = params.entry.name ? stripTargetPrefixes(params.entry.name) : "";
const handle = params.entry.handle ? stripTargetPrefixes(params.entry.handle) : "";
const candidates = [id, name, handle].map((value) => normalizeQuery(value)).filter(Boolean);
@@ -211,9 +223,15 @@ function resolveMatch(params: {
channel: ChannelId;
entries: ChannelDirectoryEntry[];
query: string;
plugin?: ChannelPlugin;
}) {
const matches = params.entries.filter((entry) =>
matchesDirectoryEntry({ channel: params.channel, entry, query: params.query }),
matchesDirectoryEntry({
channel: params.channel,
entry,
query: params.query,
plugin: params.plugin,
}),
);
if (matches.length === 0) {
return { kind: "none" as const };
@@ -232,8 +250,9 @@ async function listDirectoryEntries(params: {
runtime?: RuntimeEnv;
query?: string;
source: "cache" | "live";
plugin?: ChannelPlugin;
}): Promise<ChannelDirectoryEntry[]> {
const plugin = getChannelPlugin(params.channel);
const plugin = params.plugin ?? getChannelPlugin(params.channel);
const directory = plugin?.directory;
if (!directory) {
return [];
@@ -268,8 +287,9 @@ async function getDirectoryEntries(params: {
query?: string;
runtime?: RuntimeEnv;
preferLiveOnMiss?: boolean;
plugin?: ChannelPlugin;
}): Promise<ChannelDirectoryEntry[]> {
const signature = buildTargetResolverSignature(params.channel);
const signature = buildTargetResolverSignature(params.channel, params.plugin);
const listParams = {
cfg: params.cfg,
channel: params.channel,
@@ -277,6 +297,7 @@ async function getDirectoryEntries(params: {
kind: params.kind,
query: params.query,
runtime: params.runtime,
plugin: params.plugin,
};
const cacheKey = buildDirectoryCacheKey({
channel: params.channel,
@@ -360,16 +381,17 @@ export async function resolveMessagingTarget(params: {
runtime?: RuntimeEnv;
resolveAmbiguous?: ResolveAmbiguousMode;
unknownTargetMode?: "error" | "normalized";
plugin?: ChannelPlugin;
}): Promise<ResolveMessagingTargetResult> {
const raw = normalizeChannelTargetInput(params.input);
if (!raw) {
return { ok: false, error: new Error("Target is required") };
}
const plugin = getChannelPlugin(params.channel);
const plugin = params.plugin ?? getChannelPlugin(params.channel);
const providerLabel = plugin?.meta?.label ?? params.channel;
const hint = plugin?.messaging?.targetResolver?.hint;
const kind = detectTargetKind(params.channel, raw, params.preferredKind);
const normalizedInput = resolveNormalizedTargetInput(params.channel, raw);
const kind = detectTargetKind(params.channel, raw, params.preferredKind, plugin);
const normalizedInput = resolveNormalizedTargetInput(params.channel, raw, plugin);
const normalized = normalizedInput?.normalized ?? raw;
if (
normalizedInput &&
@@ -377,6 +399,7 @@ export async function resolveMessagingTarget(params: {
channel: params.channel,
raw: normalizedInput.raw,
normalized,
plugin,
})
) {
const resolvedIdLikeTarget = await maybeResolveIdLikeTarget({
@@ -385,6 +408,7 @@ export async function resolveMessagingTarget(params: {
input: raw,
accountId: params.accountId,
preferredKind: params.preferredKind,
plugin,
});
if (resolvedIdLikeTarget) {
return {
@@ -406,14 +430,15 @@ export async function resolveMessagingTarget(params: {
query,
runtime: params.runtime,
preferLiveOnMiss: true,
plugin,
});
const match = resolveMatch({ channel: params.channel, entries, query });
const match = resolveMatch({ channel: params.channel, entries, query, plugin });
if (match.kind === "single") {
const entry = match.entry;
return {
ok: true,
target: {
to: normalizeDirectoryEntryId(params.channel, entry),
to: normalizeDirectoryEntryId(params.channel, entry, plugin),
kind,
display: entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id),
source: "directory",
@@ -429,7 +454,7 @@ export async function resolveMessagingTarget(params: {
return {
ok: true,
target: {
to: normalizeDirectoryEntryId(params.channel, best),
to: normalizeDirectoryEntryId(params.channel, best, plugin),
kind,
display: best.name ?? best.handle ?? stripTargetPrefixes(best.id),
source: "directory",
@@ -451,6 +476,7 @@ export async function resolveMessagingTarget(params: {
input: raw,
accountId: params.accountId,
preferredKind: params.preferredKind,
plugin,
}),
);
if (resolvedFallbackTarget) {
+335 -17
View File
@@ -766,6 +766,130 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.threadId).toBeUndefined();
});
it("bootstraps plugin-channel heartbeat routes when the plugin registry is unavailable", () => {
const forum = createForumTargetTestPlugin();
setActivePluginRegistry(createTargetsTestRegistry([]));
mocks.resolveOutboundChannelPlugin.mockImplementation(
({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) =>
channel === "forum" && allowBootstrap === true ? forum : undefined,
);
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
sessionId: "sess-heartbeat-no-registry",
updatedAt: 1,
lastChannel: "forum",
lastTo: "room:ops",
},
heartbeat: {
target: "last",
},
});
expect(resolved.channel).toBe("forum");
expect(resolved.to).toBe("room:ops");
expect(mocks.resolveOutboundChannelPlugin).toHaveBeenCalledWith({
channel: "forum",
cfg: {},
allowBootstrap: true,
});
expect(
mocks.resolveOutboundChannelPlugin.mock.calls.filter(
([params]) => params.allowBootstrap === true,
),
).toHaveLength(1);
});
it("does not bypass target policy when bootstrapping plugin-channel heartbeat routes", () => {
const forum = createForumTargetTestPlugin();
setActivePluginRegistry(createTargetsTestRegistry([]));
mocks.resolveOutboundChannelPlugin.mockImplementation(
({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) =>
channel === "forum" && allowBootstrap === true ? forum : undefined,
);
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
sessionId: "sess-heartbeat-no-registry-invalid-target",
updatedAt: 1,
lastChannel: "forum",
lastTo: "invalid",
},
heartbeat: {
target: "last",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("no-target");
expect(
mocks.resolveOutboundChannelPlugin.mock.calls.filter(
([params]) => params.allowBootstrap === true,
),
).toHaveLength(1);
});
it("does not bypass account validation when bootstrapping plugin-channel heartbeat routes", () => {
const forum = createForumTargetTestPlugin();
const forumWithAccounts = {
...forum,
config: {
...forum.config,
listAccountIds: () => ["valid-account"],
},
};
setActivePluginRegistry(createTargetsTestRegistry([]));
mocks.resolveOutboundChannelPlugin.mockImplementation(
({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) =>
channel === "forum" && allowBootstrap === true ? forumWithAccounts : undefined,
);
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
sessionId: "sess-heartbeat-no-registry-invalid-account",
updatedAt: 1,
lastChannel: "forum",
lastTo: "room:ops",
},
heartbeat: {
target: "last",
accountId: "missing-account",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("unknown-account");
expect(
mocks.resolveOutboundChannelPlugin.mock.calls.filter(
([params]) => params.allowBootstrap === true,
),
).toHaveLength(1);
});
it("does not bootstrap plugin-channel heartbeat routes without a concrete target", () => {
setActivePluginRegistry(createTargetsTestRegistry([]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
sessionId: "sess-heartbeat-no-target",
updatedAt: 1,
lastChannel: "forum",
},
heartbeat: {
target: "last",
accountId: "configured-account",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("no-target");
expect(mocks.resolveOutboundChannelPlugin).not.toHaveBeenCalled();
});
it("resolves explicit heartbeat plugin targets through the outbound session route", async () => {
const cfg: OpenClawConfig = {};
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
@@ -782,25 +906,70 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.threadId).toBe(1008013);
});
it("bootstraps explicit external heartbeat targets before strict validation", () => {
const external = {
...createForumTargetTestPlugin(),
id: "external-channel",
};
mocks.resolveOutboundChannelPlugin.mockImplementation(
({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) =>
channel === "external-channel" && allowBootstrap === true ? external : undefined,
);
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
sessionId: "sess-external-account",
updatedAt: 1,
lastChannel: "external-channel",
lastTo: "room:previous",
lastAccountId: "account-2",
},
heartbeat: {
target: "external-channel",
to: "room:ops",
},
});
expect(resolved.channel).toBe("external-channel");
expect(resolved.to).toBe("room:ops");
expect(resolved.accountId).toBe("account-2");
expect(mocks.resolveOutboundChannelPlugin).toHaveBeenCalledWith({
channel: "external-channel",
cfg: {},
allowBootstrap: true,
});
});
it("blocks heartbeat targets that route to direct chats after canonicalization", async () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
setActivePluginRegistry(
createTargetsTestRegistry([
{
...alpha,
messaging: {
...alpha.messaging,
resolveOutboundSessionRoute: () => ({
sessionKey: "main:alpha:user:u123",
baseSessionKey: "main:alpha:user:u123",
peer: { kind: "direct", id: "u123" },
chatType: "direct",
from: "alpha:u123",
to: "user:u123",
}),
},
},
]),
const routedAlpha = {
...alpha,
messaging: {
...alpha.messaging,
resolveOutboundSessionRoute: () => ({
sessionKey: "main:alpha:user:u123",
baseSessionKey: "main:alpha:user:u123",
peer: { kind: "direct" as const, id: "u123" },
chatType: "direct" as const,
from: "alpha:u123",
to: "user:u123",
}),
},
};
setActivePluginRegistry(createTargetsTestRegistry([]));
mocks.resolveOutboundChannelPlugin.mockImplementation(
({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => {
if (channel !== "alpha") {
return undefined;
}
if (allowBootstrap === true) {
setActivePluginRegistry(createTargetsTestRegistry([routedAlpha]));
return routedAlpha;
}
return getActivePluginRegistry()?.channels.find((entry) => entry?.plugin?.id === channel)
?.plugin;
},
);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
@@ -876,6 +1045,155 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.chatType).toBe("group");
});
it("uses an activation-aware external plugin when canonicalizing heartbeat routes", async () => {
const external = createTestChannelPlugin({
id: "external-channel",
label: "External",
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to }) =>
to
? { ok: true as const, to: to.trim() }
: { ok: false as const, error: new Error("target required") },
},
messaging: {
targetResolver: {
resolveTarget: async ({ normalized }) => ({
to: normalized,
kind: "user",
source: "directory",
}),
},
resolveOutboundSessionRoute: ({ target, resolvedTarget }) => {
const isDirect = resolvedTarget?.kind === "user";
return {
sessionKey: `main:external-channel:${isDirect ? "user" : "group"}:${target}`,
baseSessionKey: `main:external-channel:${isDirect ? "user" : "group"}:${target}`,
peer: { kind: isDirect ? "direct" : "group", id: target },
chatType: isDirect ? "direct" : "group",
from: `external-channel:${target}`,
to: target,
};
},
},
});
const setupExternal = { ...external, messaging: undefined };
setActivePluginRegistry(createTargetsTestRegistry([setupExternal]));
mocks.resolveOutboundChannelPlugin.mockImplementation(
({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => {
if (channel !== "external-channel") {
return undefined;
}
if (allowBootstrap === true) {
return external;
}
return setupExternal;
},
);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {},
agentId: "main",
heartbeat: {
target: "external-channel",
to: "person-123",
directPolicy: "block",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
});
it("blocks direct targets from prepared external target resolvers without route hooks", async () => {
const external = createTestChannelPlugin({
id: "external-channel",
label: "External",
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to }) =>
to
? { ok: true as const, to: to.trim() }
: { ok: false as const, error: new Error("target required") },
},
messaging: {
targetResolver: {
resolveTarget: async ({ normalized }) => ({
to: normalized,
kind: "user",
source: "directory",
}),
},
},
});
setActivePluginRegistry(createTargetsTestRegistry([]));
mocks.resolveOutboundChannelPlugin.mockImplementation(
({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => {
if (channel !== "external-channel") {
return undefined;
}
if (allowBootstrap === true) {
setActivePluginRegistry(createTargetsTestRegistry([external]));
return external;
}
return getActivePluginRegistry()?.channels.find((entry) => entry?.plugin?.id === channel)
?.plugin;
},
);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {},
agentId: "main",
heartbeat: {
target: "external-channel",
to: "person-123",
directPolicy: "block",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
});
it("uses an activation-aware infer-only plugin for heartbeat direct policy", () => {
const external = createTestChannelPlugin({
id: "external-channel",
label: "External",
outbound: {
deliveryMode: "direct",
sendText: vi.fn(),
resolveTarget: ({ to }) =>
to
? { ok: true as const, to: to.trim() }
: { ok: false as const, error: new Error("target required") },
},
messaging: {
inferTargetChatType: () => "direct",
},
});
const setupExternal = { ...external, messaging: undefined };
mocks.resolveOutboundChannelPlugin.mockImplementation(
({ channel, allowBootstrap }: { channel: string; allowBootstrap?: boolean }) => {
if (channel !== "external-channel") {
return undefined;
}
return allowBootstrap === true ? external : setupExternal;
},
);
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
heartbeat: {
target: "external-channel",
to: "person-123",
directPolicy: "block",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
});
it("keeps heartbeat route canonicalization best-effort when target resolution fails", async () => {
setActivePluginRegistry(
createTargetsTestRegistry([
+107 -52
View File
@@ -3,6 +3,7 @@
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.core.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js";
@@ -104,12 +105,27 @@ export function resolveHeartbeatDeliveryTarget(params: {
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
const rawTarget = heartbeat?.target;
let target: HeartbeatTarget = "none";
let preparedExplicitPlugin: ChannelPlugin | undefined;
let preparedExplicitTo: string | undefined;
if (rawTarget === "none" || rawTarget === "last") {
target = rawTarget;
} else if (typeof rawTarget === "string") {
const normalized = normalizeDeliverableOutboundChannel(rawTarget);
if (normalized) {
target = normalized;
} else {
const explicitTo = heartbeat?.to?.trim();
if (explicitTo) {
preparedExplicitPlugin = resolveOutboundChannelPlugin({
channel: rawTarget,
cfg,
allowBootstrap: true,
});
if (preparedExplicitPlugin) {
target = preparedExplicitPlugin.id as HeartbeatTarget;
preparedExplicitTo = explicitTo;
}
}
}
} else if (
rawTarget === undefined &&
@@ -139,34 +155,57 @@ export function resolveHeartbeatDeliveryTarget(params: {
? mergeDeliveryContext(params.turnSource, deliveryContextFromSession(entry))
: undefined;
const resolvedTarget = resolveSessionDeliveryTarget({
entry,
requestedChannel: target === "last" ? "last" : target,
explicitTo: heartbeat?.to,
mode: "heartbeat",
turnSourceChannel:
resolvedTurnSource?.channel && isDeliverableMessageChannel(resolvedTurnSource.channel)
? resolvedTurnSource.channel
: undefined,
turnSourceTo: resolvedTurnSource?.to,
turnSourceAccountId: resolvedTurnSource?.accountId,
// Only pass threadId from an explicit turn source (e.g., restart sentinel's
// delivery context). Do NOT fall back to session-stored threadId here —
// heartbeat mode intentionally drops inherited thread IDs to avoid replying
// in stale threads (e.g., Slack thread_ts). The sentinel's delivery context
// carries the correct topic/thread ID when present.
turnSourceThreadId: params.turnSource?.threadId,
});
const resolvedTarget =
preparedExplicitPlugin && preparedExplicitTo
? resolveSessionDeliveryTarget({
entry,
requestedChannel: target,
explicitTo: preparedExplicitTo,
mode: "heartbeat",
})
: resolveSessionDeliveryTarget({
entry,
requestedChannel: target === "last" ? "last" : target,
explicitTo: heartbeat?.to,
mode: "heartbeat",
turnSourceChannel:
resolvedTurnSource?.channel && isDeliverableMessageChannel(resolvedTurnSource.channel)
? resolvedTurnSource.channel
: undefined,
turnSourceTo: resolvedTurnSource?.to,
turnSourceAccountId: resolvedTurnSource?.accountId,
// Only pass threadId from an explicit turn source (e.g., restart sentinel's
// delivery context). Do NOT fall back to session-stored threadId here —
// heartbeat mode intentionally drops inherited thread IDs to avoid replying
// in stale threads (e.g., Slack thread_ts). The sentinel's delivery context
// carries the correct topic/thread ID when present.
turnSourceThreadId: params.turnSource?.threadId,
});
const heartbeatAccountId = heartbeat?.accountId?.trim();
// Use explicit accountId from heartbeat config if provided, otherwise fall back to session
let effectiveAccountId = heartbeatAccountId || resolvedTarget.accountId;
if (heartbeatAccountId && resolvedTarget.channel) {
const plugin = resolveOutboundChannelPlugin({
if (!resolvedTarget.channel || !resolvedTarget.to) {
return buildNoHeartbeatDeliveryTarget({
reason: "no-target",
accountId: effectiveAccountId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
});
}
// Bootstrap once after a concrete route exists, then carry the prepared plugin
// through account validation, target policy, and allow-from comparison.
const plugin =
preparedExplicitPlugin ??
resolveOutboundChannelPlugin({
channel: resolvedTarget.channel,
cfg,
allowBootstrap: true,
});
if (heartbeatAccountId) {
const listAccountIds = plugin?.config.listAccountIds;
const accountIds = listAccountIds ? listAccountIds(cfg) : [];
if (accountIds.length > 0) {
@@ -186,23 +225,17 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
}
if (!resolvedTarget.channel || !resolvedTarget.to) {
return buildNoHeartbeatDeliveryTarget({
reason: "no-target",
const resolved = resolveOutboundTargetWithPlugin({
plugin,
target: {
channel: resolvedTarget.channel,
to: resolvedTarget.to,
cfg,
accountId: effectiveAccountId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
});
}
const resolved = resolveOutboundTarget({
channel: resolvedTarget.channel,
to: resolvedTarget.to,
cfg,
accountId: effectiveAccountId,
mode: "heartbeat",
mode: "heartbeat",
},
});
if (!resolved.ok) {
if (!resolved?.ok) {
return buildNoHeartbeatDeliveryTarget({
reason: "no-target",
accountId: effectiveAccountId,
@@ -217,6 +250,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
channel: resolvedTarget.channel,
to: resolved.to,
sessionChatType: sessionChatTypeHint,
plugin,
});
if (deliveryChatType === "direct" && heartbeat?.directPolicy === "block") {
return buildNoHeartbeatDeliveryTarget({
@@ -228,19 +262,18 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
let reason: string | undefined;
const plugin = resolveOutboundChannelPlugin({
channel: resolvedTarget.channel,
cfg,
});
if (plugin?.config.resolveAllowFrom) {
const explicit = resolveOutboundTarget({
channel: resolvedTarget.channel,
to: resolvedTarget.to,
cfg,
accountId: effectiveAccountId,
mode: "explicit",
const explicit = resolveOutboundTargetWithPlugin({
plugin,
target: {
channel: resolvedTarget.channel,
to: resolvedTarget.to,
cfg,
accountId: effectiveAccountId,
mode: "explicit",
},
});
if (explicit.ok && explicit.to !== resolved.to) {
if (explicit?.ok && explicit.to !== resolved.to) {
reason = "allowFrom-fallback";
}
}
@@ -252,6 +285,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
turnSource: params.turnSource,
entry,
resolvedTarget,
plugin,
})
? resolvedTarget.lastThreadId
: undefined;
@@ -303,8 +337,10 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
const plugin = resolveOutboundChannelPlugin({
channel: delivery.channel,
cfg: params.cfg,
allowBootstrap: true,
});
if (!plugin?.messaging?.resolveOutboundSessionRoute) {
const resolveSessionRoute = plugin?.messaging?.resolveOutboundSessionRoute;
if (!resolveSessionRoute && !plugin?.messaging?.targetResolver) {
return delivery;
}
let routeResolvedTarget: ResolvedMessagingTarget | undefined;
@@ -316,6 +352,7 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
input: deliveryTo,
accountId: delivery.accountId,
unknownTargetMode: "normalized",
plugin,
});
} catch {
// Target normalization failure should not suppress an otherwise deliverable heartbeat.
@@ -325,11 +362,23 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
if (targetResolution?.ok) {
routeResolvedTarget = targetResolution.target;
}
if (routeResolvedTarget?.kind === "user" && heartbeat?.directPolicy === "block") {
return buildNoHeartbeatDeliveryTarget({
reason: "dm-blocked",
accountId: delivery.accountId,
lastChannel: delivery.lastChannel,
lastAccountId: delivery.lastAccountId,
});
}
if (!resolveSessionRoute) {
return delivery;
}
const route = await (async () => {
try {
return await resolveOutboundSessionRoute({
cfg: params.cfg,
channel: delivery.channel as ChannelId,
plugin,
agentId: params.agentId,
accountId: delivery.accountId,
target: routeResolvedTarget?.to ?? deliveryTo,
@@ -363,6 +412,7 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
function inferChatTypeFromTarget(params: {
channel: DeliverableMessageChannel;
to: string;
plugin?: ChannelPlugin;
}): ChatType | undefined {
const to = params.to.trim();
if (!to) {
@@ -378,17 +428,19 @@ function inferChatTypeFromTarget(params: {
if (/^group:/i.test(to)) {
return "group";
}
return (
const plugin =
params.plugin ??
resolveOutboundChannelPlugin({
channel: params.channel,
})?.messaging?.inferTargetChatType?.({ to }) ?? undefined
);
});
return plugin?.messaging?.inferTargetChatType?.({ to }) ?? undefined;
}
function resolveHeartbeatDeliveryChatType(params: {
channel: DeliverableMessageChannel;
to: string;
sessionChatType?: ChatType;
plugin?: ChannelPlugin;
}): ChatType | undefined {
if (params.sessionChatType) {
return params.sessionChatType;
@@ -396,6 +448,7 @@ function resolveHeartbeatDeliveryChatType(params: {
return inferChatTypeFromTarget({
channel: params.channel,
to: params.to,
plugin: params.plugin,
});
}
@@ -406,10 +459,12 @@ function shouldReuseHeartbeatRouteThreadId(params: {
turnSource?: DeliveryContext;
entry?: SessionEntry;
resolvedTarget: SessionDeliveryTarget;
plugin?: ChannelPlugin;
}): boolean {
const channel = params.resolvedTarget.channel;
const messaging =
channel && resolveOutboundChannelPlugin({ channel, cfg: params.cfg })?.messaging;
const messaging = params.plugin
? params.plugin.messaging
: channel && resolveOutboundChannelPlugin({ channel, cfg: params.cfg })?.messaging;
return (
messaging?.preserveHeartbeatThreadIdForGroupRoute === true &&
params.resolvedTarget.threadId == null &&