mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 08:39:50 +00:00
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 head67c20aa72b. - Required merge gates passed before the squash merge. Prepared head SHA:67c20aa72bReview: 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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
77
src/infra/outbound/channel-bootstrap.runtime.test.ts
Normal file
77
src/infra/outbound/channel-bootstrap.runtime.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user