diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ccbc87d00c..52ebdee6904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/channels/plugins/registry-loader.ts b/src/channels/plugins/registry-loader.ts index aad472a5b4b..7dec6ab5ff4 100644 --- a/src/channels/plugins/registry-loader.ts +++ b/src/channels/plugins/registry-loader.ts @@ -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 = ( @@ -10,11 +10,24 @@ export function createChannelRegistryLoader( resolveValue: ChannelRegistryValueResolver, ): (id: ChannelId) => Promise { return async (id: ChannelId): Promise => { - const registry = getActivePluginChannelRegistry(); - const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id); - if (!pluginEntry) { - return undefined; + const resolveFromRegistry = ( + registry: ReturnType, + ): 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; }; } diff --git a/src/infra/outbound/channel-bootstrap.runtime.test.ts b/src/infra/outbound/channel-bootstrap.runtime.test.ts new file mode 100644 index 00000000000..8db7582267c --- /dev/null +++ b/src/infra/outbound/channel-bootstrap.runtime.test.ts @@ -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(); + }); +}); diff --git a/src/infra/outbound/channel-bootstrap.runtime.ts b/src/infra/outbound/channel-bootstrap.runtime.ts index 6c0c5aee934..e0c65de91a6 100644 --- a/src/infra/outbound/channel-bootstrap.runtime.ts +++ b/src/infra/outbound/channel-bootstrap.runtime.ts @@ -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; } diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index cfa79eb439a..8c092c907f4 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -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([ diff --git a/src/plugins/runtime.channel-pin.test.ts b/src/plugins/runtime.channel-pin.test.ts index 6a7c436009f..69ef42fbf71 100644 --- a/src/plugins/runtime.channel-pin.test.ts +++ b/src/plugins/runtime.channel-pin.test.ts @@ -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();