fix(outbound): resolve send-capable channel registry (#83733)

Summary:
- The PR changes outbound channel registry loading and bootstrap to fall back from pinned setup-only channel entries to the active runtime registry, with regression tests and a changelog entry.
- Reproducibility: yes. at source level. Current main can select a pinned setup-only channel entry and skip th ... module live output showing delivery after the fallback; I did not run local tests in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(outbound): resolve send-capable channel registry

Validation:
- ClawSweeper review passed for head 67c20aa72b.
- Required merge gates passed before the squash merge.

Prepared head SHA: 67c20aa72b
Review: https://github.com/openclaw/openclaw/pull/83733#issuecomment-4481084888

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
Andy Ye
2026-05-18 13:30:51 -07:00
committed by GitHub
parent 424c6d0a5f
commit b2c5ba6d4c
6 changed files with 174 additions and 9 deletions

View File

@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
- Control UI: render live tool progress from session-scoped `session.tool` Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.
- Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle.
- Browser: enforce current-tab URL allowlist checks for `/act` evaluate/batch actions and `/highlight` routes while leaving tab-management actions unblocked. (#78523)
- CI: require real-behavior-proof verdict markers to come from the ClawSweeper GitHub App before accepting exact-head proof. (#83692)
- Models: show the effective OpenAI/Codex auth profile in `/models` provider headers instead of falling back to the OpenAI env-key label. (#83697) Thanks @yu-xin-c.

View File

@@ -1,5 +1,5 @@
import type { PluginChannelRegistration } from "../../plugins/registry-types.js";
import { getActivePluginChannelRegistry } from "../../plugins/runtime.js";
import { getActivePluginChannelRegistry, getActivePluginRegistry } from "../../plugins/runtime.js";
import type { ChannelId } from "./channel-id.types.js";
type ChannelRegistryValueResolver<TValue> = (
@@ -10,11 +10,24 @@ export function createChannelRegistryLoader<TValue>(
resolveValue: ChannelRegistryValueResolver<TValue>,
): (id: ChannelId) => Promise<TValue | undefined> {
return async (id: ChannelId): Promise<TValue | undefined> => {
const registry = getActivePluginChannelRegistry();
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
if (!pluginEntry) {
return undefined;
const resolveFromRegistry = (
registry: ReturnType<typeof getActivePluginRegistry>,
): TValue | undefined => {
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
return pluginEntry ? resolveValue(pluginEntry) : undefined;
};
const channelRegistry = getActivePluginChannelRegistry();
const channelValue = resolveFromRegistry(channelRegistry);
if (channelValue !== undefined) {
return channelValue;
}
return resolveValue(pluginEntry);
const activeRegistry = getActivePluginRegistry();
if (activeRegistry && activeRegistry !== channelRegistry) {
return resolveFromRegistry(activeRegistry);
}
return undefined;
};
}

View File

@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import {
pinActivePluginChannelRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "../../plugins/runtime.js";
const loaderMocks = vi.hoisted(() => ({
resolveRuntimePluginRegistry: vi.fn(),
}));
vi.mock("../../plugins/loader.js", () => ({
resolveRuntimePluginRegistry: loaderMocks.resolveRuntimePluginRegistry,
}));
const { bootstrapOutboundChannelPlugin, resetOutboundChannelBootstrapStateForTests } =
await import("./channel-bootstrap.runtime.js");
const discordConfig = {
channels: {
discord: {},
},
} satisfies OpenClawConfig;
describe("bootstrapOutboundChannelPlugin", () => {
afterEach(() => {
loaderMocks.resolveRuntimePluginRegistry.mockReset();
resetOutboundChannelBootstrapStateForTests();
resetPluginRuntimeStateForTest();
});
it("bootstraps when the selected channel registry has only a setup shell", () => {
const registry = createEmptyPluginRegistry();
registry.channels = [
{
pluginId: "discord",
plugin: { id: "discord", meta: {} },
source: "setup",
},
] as never;
setActivePluginRegistry(registry);
pinActivePluginChannelRegistry(registry);
bootstrapOutboundChannelPlugin({
channel: "discord",
cfg: discordConfig,
});
expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
});
it("skips bootstrap when the selected channel entry can already send", () => {
const registry = createEmptyPluginRegistry();
registry.channels = [
{
pluginId: "discord",
plugin: {
id: "discord",
meta: {},
outbound: { sendText: async () => ({ messageId: "1" }) },
},
source: "runtime",
},
] as never;
setActivePluginRegistry(registry);
pinActivePluginChannelRegistry(registry);
bootstrapOutboundChannelPlugin({
channel: "discord",
cfg: discordConfig,
});
expect(loaderMocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled();
});
});

View File

@@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveRuntimePluginRegistry } from "../../plugins/loader.js";
import type { PluginChannelRegistration } from "../../plugins/registry-types.js";
import {
getActivePluginChannelRegistry,
getActivePluginChannelRegistryVersion,
@@ -14,6 +15,10 @@ export function resetOutboundChannelBootstrapStateForTests(): void {
bootstrapAttempts.clear();
}
function channelEntryCanSend(entry: PluginChannelRegistration | undefined): boolean {
return Boolean(entry?.plugin?.outbound?.sendText ?? entry?.plugin?.message?.send?.text);
}
export function bootstrapOutboundChannelPlugin(params: {
channel: DeliverableMessageChannel;
cfg?: OpenClawConfig;
@@ -24,10 +29,10 @@ export function bootstrapOutboundChannelPlugin(params: {
}
const activeChannelRegistry = getActivePluginChannelRegistry();
const activeHasRequestedChannel = activeChannelRegistry?.channels?.some(
const activeChannelEntry = activeChannelRegistry?.channels?.find(
(entry) => entry?.plugin?.id === params.channel,
);
if (activeHasRequestedChannel) {
if (channelEntryCanSend(activeChannelEntry)) {
return;
}

View File

@@ -9,11 +9,16 @@ import { createHookRunner } from "../../plugins/hooks.js";
import { addTestHook } from "../../plugins/hooks.test-helpers.js";
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
import {
pinActivePluginChannelRegistry,
releasePinnedPluginChannelRegistry,
setActivePluginRegistry,
} from "../../plugins/runtime.js";
import type { PluginHookRegistration } from "../../plugins/types.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
createChannelTestPluginBase,
createOutboundTestPlugin,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import {
onInternalDiagnosticEvent,
@@ -318,6 +323,43 @@ describe("deliverOutboundPayloads", () => {
setActivePluginRegistry(emptyRegistry);
});
it("delivers through full active plugin when pinned setup channel has no sender", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
const setupRegistry = createTestRegistry([
{
pluginId: "matrix",
source: "setup",
plugin: createChannelTestPluginBase({ id: "matrix" }),
},
]);
const runtimeRegistry = createTestRegistry([
{
pluginId: "matrix",
source: "runtime",
plugin: createOutboundTestPlugin({ id: "matrix", outbound: matrixOutboundForTest }),
},
]);
setActivePluginRegistry(setupRegistry);
pinActivePluginChannelRegistry(setupRegistry);
setActivePluginRegistry(runtimeRegistry);
const results = await deliverOutboundPayloads({
cfg: matrixChunkConfig,
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hello from queue" }],
deps: { matrix: sendMatrix },
});
expect(sendMatrix).toHaveBeenCalledWith("!room:example", "hello from queue", {
cfg: matrixChunkConfig,
accountId: undefined,
gifPlayback: undefined,
});
expect(results).toEqual([{ channel: "matrix", messageId: "m1", roomId: "!room:example" }]);
});
it("reports unsupported durable final delivery when required capabilities are missing", async () => {
setActivePluginRegistry(
createTestRegistry([

View File

@@ -229,6 +229,33 @@ describe("channel registry pinning", () => {
expect(adapter).toBe(outboundAdapter);
});
it("loadChannelOutboundAdapter falls back to active registry when pinned setup entry cannot send", async () => {
const outboundAdapter = { sendText: async () => ({ messageId: "1" }) };
const startup = createEmptyPluginRegistry();
startup.channels = [
{
pluginId: "discord",
plugin: { id: "discord", meta: {} },
source: "setup",
},
] as never;
const replacement = createEmptyPluginRegistry();
replacement.channels = [
{
pluginId: "discord",
plugin: { id: "discord", meta: {}, outbound: outboundAdapter },
source: "runtime",
},
] as never;
setActivePluginRegistry(startup);
pinActivePluginChannelRegistry(startup);
setActivePluginRegistry(replacement);
const adapter = await loadChannelOutboundAdapter("discord");
expect(adapter).toBe(outboundAdapter);
});
it("keeps pinned channel registry agent-event subscriptions live after active registry replacement", () => {
const observed: string[] = [];
const startup = createEmptyPluginRegistry();