Skip to content

Commit b2c5ba6

Browse files
TurboTheTurtleclawsweeper[bot]Takhoffman
authored
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 67c20aa. - Required merge gates passed before the squash merge. Prepared head SHA: 67c20aa Review: #83733 (comment) 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>
1 parent 424c6d0 commit b2c5ba6

6 files changed

Lines changed: 174 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
4949

5050
- 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.
5151
- 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.
52+
- Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle.
5253
- Browser: enforce current-tab URL allowlist checks for `/act` evaluate/batch actions and `/highlight` routes while leaving tab-management actions unblocked. (#78523)
5354
- CI: require real-behavior-proof verdict markers to come from the ClawSweeper GitHub App before accepting exact-head proof. (#83692)
5455
- 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.
Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { PluginChannelRegistration } from "../../plugins/registry-types.js";
2-
import { getActivePluginChannelRegistry } from "../../plugins/runtime.js";
2+
import { getActivePluginChannelRegistry, getActivePluginRegistry } from "../../plugins/runtime.js";
33
import type { ChannelId } from "./channel-id.types.js";
44

55
type ChannelRegistryValueResolver<TValue> = (
@@ -10,11 +10,24 @@ export function createChannelRegistryLoader<TValue>(
1010
resolveValue: ChannelRegistryValueResolver<TValue>,
1111
): (id: ChannelId) => Promise<TValue | undefined> {
1212
return async (id: ChannelId): Promise<TValue | undefined> => {
13-
const registry = getActivePluginChannelRegistry();
14-
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
15-
if (!pluginEntry) {
16-
return undefined;
13+
const resolveFromRegistry = (
14+
registry: ReturnType<typeof getActivePluginRegistry>,
15+
): TValue | undefined => {
16+
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
17+
return pluginEntry ? resolveValue(pluginEntry) : undefined;
18+
};
19+
20+
const channelRegistry = getActivePluginChannelRegistry();
21+
const channelValue = resolveFromRegistry(channelRegistry);
22+
if (channelValue !== undefined) {
23+
return channelValue;
24+
}
25+
26+
const activeRegistry = getActivePluginRegistry();
27+
if (activeRegistry && activeRegistry !== channelRegistry) {
28+
return resolveFromRegistry(activeRegistry);
1729
}
18-
return resolveValue(pluginEntry);
30+
31+
return undefined;
1932
};
2033
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
3+
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
4+
import {
5+
pinActivePluginChannelRegistry,
6+
resetPluginRuntimeStateForTest,
7+
setActivePluginRegistry,
8+
} from "../../plugins/runtime.js";
9+
10+
const loaderMocks = vi.hoisted(() => ({
11+
resolveRuntimePluginRegistry: vi.fn(),
12+
}));
13+
14+
vi.mock("../../plugins/loader.js", () => ({
15+
resolveRuntimePluginRegistry: loaderMocks.resolveRuntimePluginRegistry,
16+
}));
17+
18+
const { bootstrapOutboundChannelPlugin, resetOutboundChannelBootstrapStateForTests } =
19+
await import("./channel-bootstrap.runtime.js");
20+
21+
const discordConfig = {
22+
channels: {
23+
discord: {},
24+
},
25+
} satisfies OpenClawConfig;
26+
27+
describe("bootstrapOutboundChannelPlugin", () => {
28+
afterEach(() => {
29+
loaderMocks.resolveRuntimePluginRegistry.mockReset();
30+
resetOutboundChannelBootstrapStateForTests();
31+
resetPluginRuntimeStateForTest();
32+
});
33+
34+
it("bootstraps when the selected channel registry has only a setup shell", () => {
35+
const registry = createEmptyPluginRegistry();
36+
registry.channels = [
37+
{
38+
pluginId: "discord",
39+
plugin: { id: "discord", meta: {} },
40+
source: "setup",
41+
},
42+
] as never;
43+
setActivePluginRegistry(registry);
44+
pinActivePluginChannelRegistry(registry);
45+
46+
bootstrapOutboundChannelPlugin({
47+
channel: "discord",
48+
cfg: discordConfig,
49+
});
50+
51+
expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
52+
});
53+
54+
it("skips bootstrap when the selected channel entry can already send", () => {
55+
const registry = createEmptyPluginRegistry();
56+
registry.channels = [
57+
{
58+
pluginId: "discord",
59+
plugin: {
60+
id: "discord",
61+
meta: {},
62+
outbound: { sendText: async () => ({ messageId: "1" }) },
63+
},
64+
source: "runtime",
65+
},
66+
] as never;
67+
setActivePluginRegistry(registry);
68+
pinActivePluginChannelRegistry(registry);
69+
70+
bootstrapOutboundChannelPlugin({
71+
channel: "discord",
72+
cfg: discordConfig,
73+
});
74+
75+
expect(loaderMocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled();
76+
});
77+
});

src/infra/outbound/channel-bootstrap.runtime.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag
22
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
33
import type { OpenClawConfig } from "../../config/types.openclaw.js";
44
import { resolveRuntimePluginRegistry } from "../../plugins/loader.js";
5+
import type { PluginChannelRegistration } from "../../plugins/registry-types.js";
56
import {
67
getActivePluginChannelRegistry,
78
getActivePluginChannelRegistryVersion,
@@ -14,6 +15,10 @@ export function resetOutboundChannelBootstrapStateForTests(): void {
1415
bootstrapAttempts.clear();
1516
}
1617

18+
function channelEntryCanSend(entry: PluginChannelRegistration | undefined): boolean {
19+
return Boolean(entry?.plugin?.outbound?.sendText ?? entry?.plugin?.message?.send?.text);
20+
}
21+
1722
export function bootstrapOutboundChannelPlugin(params: {
1823
channel: DeliverableMessageChannel;
1924
cfg?: OpenClawConfig;
@@ -24,10 +29,10 @@ export function bootstrapOutboundChannelPlugin(params: {
2429
}
2530

2631
const activeChannelRegistry = getActivePluginChannelRegistry();
27-
const activeHasRequestedChannel = activeChannelRegistry?.channels?.some(
32+
const activeChannelEntry = activeChannelRegistry?.channels?.find(
2833
(entry) => entry?.plugin?.id === params.channel,
2934
);
30-
if (activeHasRequestedChannel) {
35+
if (channelEntryCanSend(activeChannelEntry)) {
3136
return;
3237
}
3338

src/infra/outbound/deliver.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@ import { createHookRunner } from "../../plugins/hooks.js";
99
import { addTestHook } from "../../plugins/hooks.test-helpers.js";
1010
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
1111
import {
12+
pinActivePluginChannelRegistry,
1213
releasePinnedPluginChannelRegistry,
1314
setActivePluginRegistry,
1415
} from "../../plugins/runtime.js";
1516
import type { PluginHookRegistration } from "../../plugins/types.js";
16-
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
17+
import {
18+
createChannelTestPluginBase,
19+
createOutboundTestPlugin,
20+
createTestRegistry,
21+
} from "../../test-utils/channel-plugins.js";
1722
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
1823
import {
1924
onInternalDiagnosticEvent,
@@ -318,6 +323,43 @@ describe("deliverOutboundPayloads", () => {
318323
setActivePluginRegistry(emptyRegistry);
319324
});
320325

326+
it("delivers through full active plugin when pinned setup channel has no sender", async () => {
327+
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
328+
const setupRegistry = createTestRegistry([
329+
{
330+
pluginId: "matrix",
331+
source: "setup",
332+
plugin: createChannelTestPluginBase({ id: "matrix" }),
333+
},
334+
]);
335+
const runtimeRegistry = createTestRegistry([
336+
{
337+
pluginId: "matrix",
338+
source: "runtime",
339+
plugin: createOutboundTestPlugin({ id: "matrix", outbound: matrixOutboundForTest }),
340+
},
341+
]);
342+
343+
setActivePluginRegistry(setupRegistry);
344+
pinActivePluginChannelRegistry(setupRegistry);
345+
setActivePluginRegistry(runtimeRegistry);
346+
347+
const results = await deliverOutboundPayloads({
348+
cfg: matrixChunkConfig,
349+
channel: "matrix",
350+
to: "!room:example",
351+
payloads: [{ text: "hello from queue" }],
352+
deps: { matrix: sendMatrix },
353+
});
354+
355+
expect(sendMatrix).toHaveBeenCalledWith("!room:example", "hello from queue", {
356+
cfg: matrixChunkConfig,
357+
accountId: undefined,
358+
gifPlayback: undefined,
359+
});
360+
expect(results).toEqual([{ channel: "matrix", messageId: "m1", roomId: "!room:example" }]);
361+
});
362+
321363
it("reports unsupported durable final delivery when required capabilities are missing", async () => {
322364
setActivePluginRegistry(
323365
createTestRegistry([

src/plugins/runtime.channel-pin.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,33 @@ describe("channel registry pinning", () => {
229229
expect(adapter).toBe(outboundAdapter);
230230
});
231231

232+
it("loadChannelOutboundAdapter falls back to active registry when pinned setup entry cannot send", async () => {
233+
const outboundAdapter = { sendText: async () => ({ messageId: "1" }) };
234+
const startup = createEmptyPluginRegistry();
235+
startup.channels = [
236+
{
237+
pluginId: "discord",
238+
plugin: { id: "discord", meta: {} },
239+
source: "setup",
240+
},
241+
] as never;
242+
const replacement = createEmptyPluginRegistry();
243+
replacement.channels = [
244+
{
245+
pluginId: "discord",
246+
plugin: { id: "discord", meta: {}, outbound: outboundAdapter },
247+
source: "runtime",
248+
},
249+
] as never;
250+
251+
setActivePluginRegistry(startup);
252+
pinActivePluginChannelRegistry(startup);
253+
setActivePluginRegistry(replacement);
254+
255+
const adapter = await loadChannelOutboundAdapter("discord");
256+
expect(adapter).toBe(outboundAdapter);
257+
});
258+
232259
it("keeps pinned channel registry agent-event subscriptions live after active registry replacement", () => {
233260
const observed: string[] = [];
234261
const startup = createEmptyPluginRegistry();

0 commit comments

Comments
 (0)