Files
openclaw/src/plugins/runtime.channel-pin.test.ts
GodsBoy bc9c074b2c fix(channels): use pinned channel registry for outbound adapter resolution
loadChannelOutboundAdapter (via createChannelRegistryLoader) was reading
from getActivePluginRegistry() — the unpinned active registry that gets
replaced whenever loadOpenClawPlugins() runs (config schema reads, plugin
status queries, tool listings, etc.).

After replacement, the active registry may omit channel entries or carry
them in setup mode without outbound adapters, causing:

  Outbound not configured for channel: telegram

The channel inbound path already uses the pinned registry
(getActivePluginChannelRegistry) which is frozen at gateway startup and
survives all subsequent registry replacements. This commit aligns the
outbound path to use the same pinned surface.

Adds a regression test that pins a registry with a telegram outbound
adapter, replaces the active registry with an empty one, then asserts
loadChannelOutboundAdapter still resolves the adapter.

Fixes #54745
Fixes #54013
2026-03-29 12:54:14 +05:30

200 lines
7.0 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { loadChannelOutboundAdapter } from "../channels/plugins/outbound/load.js";
import { getChannelPlugin } from "../channels/plugins/registry.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import {
getActivePluginChannelRegistryVersion,
getActivePluginRegistryVersion,
getActivePluginChannelRegistry,
pinActivePluginChannelRegistry,
releasePinnedPluginChannelRegistry,
requireActivePluginChannelRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "./runtime.js";
function createRegistryWithChannel(pluginId = "demo-channel") {
const registry = createEmptyPluginRegistry();
const plugin = { id: pluginId, meta: {} } as never;
registry.channels = [{ plugin }] as never;
return { registry, plugin };
}
function createChannelRegistryPair(pluginId = "demo-channel") {
return {
first: createRegistryWithChannel(pluginId),
second: createRegistryWithChannel(pluginId),
};
}
function createRegistrySet() {
return {
startup: createEmptyPluginRegistry(),
replacement: createEmptyPluginRegistry(),
unrelated: createEmptyPluginRegistry(),
};
}
function expectActiveChannelRegistry(registry: ReturnType<typeof createEmptyPluginRegistry>) {
expect(getActivePluginChannelRegistry()).toBe(registry);
}
function expectPinnedChannelRegistry(
startupRegistry: ReturnType<typeof createEmptyPluginRegistry>,
replacementRegistry: ReturnType<typeof createEmptyPluginRegistry>,
) {
setActivePluginRegistry(startupRegistry);
pinActivePluginChannelRegistry(startupRegistry);
setActivePluginRegistry(replacementRegistry);
expectActiveChannelRegistry(startupRegistry);
}
function expectResetClearsPinnedChannelRegistry(params: {
startupRegistry: ReturnType<typeof createEmptyPluginRegistry>;
freshRegistry: ReturnType<typeof createEmptyPluginRegistry>;
}) {
setActivePluginRegistry(params.startupRegistry);
pinActivePluginChannelRegistry(params.startupRegistry);
resetPluginRuntimeStateForTest();
setActivePluginRegistry(params.freshRegistry);
expectActiveChannelRegistry(params.freshRegistry);
}
function expectChannelRegistrySwap(params: {
startupRegistry: ReturnType<typeof createEmptyPluginRegistry>;
replacementRegistry: ReturnType<typeof createEmptyPluginRegistry>;
pin?: boolean;
releaseRegistry?: ReturnType<typeof createEmptyPluginRegistry>;
expectedDuringSwap: ReturnType<typeof createEmptyPluginRegistry>;
expectedAfterRelease: ReturnType<typeof createEmptyPluginRegistry>;
}) {
setActivePluginRegistry(params.startupRegistry);
if (params.pin) {
pinActivePluginChannelRegistry(params.startupRegistry);
}
setActivePluginRegistry(params.replacementRegistry);
expectActiveChannelRegistry(params.expectedDuringSwap);
if (params.pin && params.releaseRegistry) {
releasePinnedPluginChannelRegistry(params.releaseRegistry);
}
expectActiveChannelRegistry(params.expectedAfterRelease);
}
describe("channel registry pinning", () => {
afterEach(() => {
resetPluginRuntimeStateForTest();
});
it("returns the active registry when not pinned", () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
expectActiveChannelRegistry(registry);
});
it("preserves pinned channel registry across setActivePluginRegistry calls", () => {
const { registry: startup } = createRegistryWithChannel();
// A subsequent registry swap (e.g., config-schema load) must not evict channels.
const replacement = createEmptyPluginRegistry();
expectPinnedChannelRegistry(startup, replacement);
expect(getActivePluginChannelRegistry()!.channels).toHaveLength(1);
});
it("re-pin invalidates cached channel lookups", () => {
const { first, second } = createChannelRegistryPair();
const { registry: setup, plugin: setupPlugin } = first;
setActivePluginRegistry(setup);
pinActivePluginChannelRegistry(setup);
expect(getChannelPlugin("demo-channel")).toBe(setupPlugin);
const { registry: full, plugin: fullPlugin } = second;
setActivePluginRegistry(full);
expect(getChannelPlugin("demo-channel")).toBe(setupPlugin);
const activeVersionBeforeRepin = getActivePluginRegistryVersion();
const channelVersionBeforeRepin = getActivePluginChannelRegistryVersion();
pinActivePluginChannelRegistry(full);
expect(getActivePluginRegistryVersion()).toBe(activeVersionBeforeRepin);
expect(getActivePluginChannelRegistryVersion()).toBe(channelVersionBeforeRepin + 1);
expect(getChannelPlugin("demo-channel")).toBe(fullPlugin);
});
it.each([
{
name: "updates channel registry on swap when not pinned",
pin: false,
releasePinnedRegistry: false,
expectDuringPin: false,
expectAfterSwap: "second",
},
{
name: "release restores live-tracking behavior",
pin: true,
releasePinnedRegistry: true,
expectDuringPin: true,
expectAfterSwap: "second",
},
{
name: "release is a no-op when the pinned registry does not match",
pin: true,
releasePinnedRegistry: false,
expectDuringPin: true,
expectAfterSwap: "first",
},
] as const)("$name", ({ pin, releasePinnedRegistry, expectDuringPin, expectAfterSwap }) => {
const { startup, replacement, unrelated } = createRegistrySet();
expectChannelRegistrySwap({
startupRegistry: startup,
replacementRegistry: replacement,
...(pin ? { pin: true } : {}),
...(pin ? { releaseRegistry: releasePinnedRegistry ? startup : unrelated } : {}),
expectedDuringSwap: expectDuringPin ? startup : replacement,
expectedAfterRelease: expectAfterSwap === "second" ? replacement : startup,
});
});
it("requireActivePluginChannelRegistry creates a registry when none exists", () => {
resetPluginRuntimeStateForTest();
const registry = requireActivePluginChannelRegistry();
expect(registry).toBeDefined();
expect(registry.channels).toEqual([]);
});
it("resetPluginRuntimeStateForTest clears channel pin", () => {
const { startup, replacement: fresh } = createRegistrySet();
expectResetClearsPinnedChannelRegistry({
startupRegistry: startup,
freshRegistry: fresh,
});
});
it("loadChannelOutboundAdapter resolves from pinned registry after active registry replacement", async () => {
const outboundAdapter = { send: async () => ({ messageId: "1" }) };
const startup = createEmptyPluginRegistry();
startup.channels = [
{
pluginId: "telegram",
plugin: { id: "telegram", meta: {}, outbound: outboundAdapter },
source: "test",
},
] as never;
setActivePluginRegistry(startup);
pinActivePluginChannelRegistry(startup);
// Simulate a post-boot registry replacement (e.g. config-schema load, plugin status query).
const replacement = createEmptyPluginRegistry();
setActivePluginRegistry(replacement);
// The outbound loader must still find the telegram adapter from the pinned registry.
const adapter = await loadChannelOutboundAdapter("telegram");
expect(adapter).toBe(outboundAdapter);
});
});