diff --git a/CHANGELOG.md b/CHANGELOG.md index a760110686a..45a7e106426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Auto-reply/ACP: wait for same-channel block reply delivery before starting tool work, while still honoring ACP dispatch aborts so stopped turns do not wait on slow channel sends. (#83722) Thanks @IWhatsskill. - Codex/ACP: mark required child-run completions that only report progress, omit a final deliverable, or fail requester delivery as blocked while preserving real final reports. (#85110) Thanks @IWhatsskill. - Channels: treat bare abort messages such as `stop`, `abort`, and `wait` as immediate control commands in inbound debounce paths so stop requests are not delayed behind pending message coalescing. (#83348) Thanks @IWhatsskill. +- Channels/message tool: resolve configured external channel plugins during in-agent channel selection, so `openclaw agent --local` message-tool sends no longer report an available channel as unavailable. (#85022) Thanks @Kaspre. - Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle. - Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong. - WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch. diff --git a/src/infra/outbound/channel-resolution.test.ts b/src/infra/outbound/channel-resolution.test.ts index 98ddc84ffa9..915e1cd8b5c 100644 --- a/src/infra/outbound/channel-resolution.test.ts +++ b/src/infra/outbound/channel-resolution.test.ts @@ -121,6 +121,22 @@ describe("outbound channel resolution", () => { expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); }); + it("returns a bundled plugin without bootstrapping", async () => { + const plugin = { id: "alpha" }; + getLoadedChannelPluginMock.mockReturnValue(undefined); + getChannelPluginMock.mockReturnValue(plugin); + const channelResolution = await importChannelResolution("bundled-plugin"); + + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "alpha", + cfg: {} as never, + allowBootstrap: true, + }), + ).toBe(plugin); + expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + }); + it("falls back to the active registry when getChannelPlugin misses", async () => { const plugin = { id: "alpha" }; getChannelPluginMock.mockReturnValue(undefined); diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 33f897cd223..90e38fb837c 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -192,8 +192,13 @@ export function resolveOutboundChannelPlugin(params: { return directCurrent; } + const bundledCurrent = resolve(); + if (bundledCurrent) { + return bundledCurrent; + } + if (params.allowBootstrap !== true) { - return resolve(); + return undefined; } maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg }); diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index f831621f77e..b40b028bba4 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -245,6 +245,36 @@ describe("resolveMessageChannelSelection", () => { verify?.(setupResult as never); }); + it("allows bootstrap while checking explicit and fallback channels", async () => { + const cfg = {} as never; + mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => + channel === "beta" ? { id: "beta" } : undefined, + ); + + await expect( + expectResolvedSelection({ + cfg, + channel: "alpha", + fallbackChannel: "beta", + }), + ).resolves.toEqual({ + channel: "beta", + configured: [], + source: "tool-context-fallback", + }); + + expect(mocks.resolveOutboundChannelPlugin).toHaveBeenNthCalledWith(1, { + channel: "alpha", + cfg, + allowBootstrap: true, + }); + expect(mocks.resolveOutboundChannelPlugin).toHaveBeenNthCalledWith(2, { + channel: "beta", + cfg, + allowBootstrap: true, + }); + }); + it.each([ { params: { cfg: {} as never, channel: "channel:C123", fallbackChannel: "not-a-channel" }, diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 55b97d533ab..2a64ffdb247 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -49,9 +49,19 @@ function resolveAvailableKnownChannel(params: { if (!normalized) { return undefined; } + // Pass `allowBootstrap: true` so the in-agent message tool path can resolve + // outbound channels in processes where external channel adapters have not + // been eagerly loaded (e.g. `openclaw agent --local`). Already-loaded and + // bundled plugins still resolve through side-effect-free fast paths first. + // Without the bootstrap fallback, official external channels can surface as + // the recurring "Channel is unavailable" error on `--local`-routed + // dispatches that the CLI send-path could deliver to. + // Adjacent to #77254 (cron-announce / final-reply paths); this closes the + // remaining in-agent caller in the same family. return resolveOutboundChannelPlugin({ channel: normalized, cfg: params.cfg, + allowBootstrap: true, }) ? normalized : undefined;