From 0c2ae713662cc701ee68dbe39adcbb9c454e80a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:45:09 -0700 Subject: [PATCH] fix(outbound): preserve channel registry during provider snapshots --- extensions/anthropic/index.ts | 4 ++-- src/commands/status.scan.ts | 9 ++++++--- src/infra/outbound/channel-resolution.test.ts | 17 +++++++++++++++++ src/infra/outbound/channel-resolution.ts | 5 ++++- src/infra/outbound/deliver.ts | 8 ++++++++ src/plugins/providers.test.ts | 6 ++++++ src/plugins/providers.ts | 5 +++-- ui/src/ui/views/chat.test.ts | 4 ++-- 8 files changed, 48 insertions(+), 10 deletions(-) diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index ca659092d24..7fe35e439ee 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -10,7 +10,7 @@ import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { parseDurationMs } from "../../src/cli/parse-duration.js"; import { normalizeSecretInputModeInput, - promptSecretRefForOnboarding, + promptSecretRefForSetup, resolveSecretInputModeForEnvSelection, } from "../../src/commands/auth-choice.apply-helpers.js"; import { buildTokenProfileId, validateAnthropicSetupToken } from "../../src/commands/auth-token.js"; @@ -144,7 +144,7 @@ async function runAnthropicSetupToken(ctx: ProviderAuthContext): Promise> | null; gatewayReachable: boolean; gatewaySelf: ReturnType; - channelIssues: ChannelStatusIssues; + channelIssues: ReturnType; agentStatus: Awaited>; - channels: ChannelsTable; + channels: Awaited>; summary: Awaited>; memory: MemoryStatusSnapshot | null; memoryPlugin: MemoryPluginStatus; diff --git a/src/infra/outbound/channel-resolution.test.ts b/src/infra/outbound/channel-resolution.test.ts index 407994b152f..3d8f8c4fbdd 100644 --- a/src/infra/outbound/channel-resolution.test.ts +++ b/src/infra/outbound/channel-resolution.test.ts @@ -133,6 +133,23 @@ describe("outbound channel resolution", () => { expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); }); + it("bootstraps when the active registry has other channels but not the requested one", async () => { + const plugin = { id: "telegram" }; + getChannelPluginMock.mockReturnValueOnce(undefined).mockReturnValueOnce(plugin); + getActivePluginRegistryMock.mockReturnValue({ + channels: [{ plugin: { id: "discord" } }], + }); + const channelResolution = await importChannelResolution("bootstrap-missing-target"); + + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "telegram", + cfg: { channels: {} } as never, + }), + ).toBe(plugin); + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); + }); + it("retries bootstrap after a transient load failure", async () => { getChannelPluginMock.mockReturnValue(undefined); loadOpenClawPluginsMock.mockImplementationOnce(() => { diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 041e8c60480..c39ff8bb210 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -33,7 +33,10 @@ function maybeBootstrapChannelPlugin(params: { } const activeRegistry = getActivePluginRegistry(); - if ((activeRegistry?.channels?.length ?? 0) > 0) { + const activeHasRequestedChannel = activeRegistry?.channels?.some( + (entry) => entry?.plugin?.id === params.channel, + ); + if (activeHasRequestedChannel) { return; } diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 509ff278a1d..6065667439e 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -34,6 +34,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { throwIfAborted } from "./abort.js"; +import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; import { ackDelivery, enqueueDelivery, failDelivery } from "./delivery-queue.js"; import type { OutboundIdentity } from "./identity.js"; import type { DeliveryMirror } from "./mirror.js"; @@ -113,6 +114,13 @@ type ChannelHandlerParams = { // Channel docking: outbound delivery delegates to plugin.outbound adapters. async function createChannelHandler(params: ChannelHandlerParams): Promise { + // Recover channel plugins the same way target resolution does so direct cron + // delivery still works when a prior test or lazy path left the active plugin + // registry empty. + resolveOutboundChannelPlugin({ + channel: params.channel, + cfg: params.cfg, + }); const outbound = await loadChannelOutboundAdapter(params.channel); const handler = createPluginHandler({ ...params, outbound }); if (!handler) { diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index a601336e5b9..1a365d71e87 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -38,6 +38,8 @@ describe("resolvePluginProviders", () => { expect.objectContaining({ workspaceDir: "/workspace/explicit", env, + cache: false, + activate: false, }), ); }); @@ -59,6 +61,8 @@ describe("resolvePluginProviders", () => { allow: expect.arrayContaining(["openrouter", "google", "kilocode", "moonshot"]), }), }), + cache: false, + activate: false, }), ); }); @@ -76,6 +80,8 @@ describe("resolvePluginProviders", () => { allow: expect.arrayContaining(["openai", "moonshot", "zai"]), }), }), + cache: false, + activate: false, }), ); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 37f937d5a91..97a4dc0b32d 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -35,6 +35,7 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "venice", "vercel-ai-gateway", "volcengine", + "xai", "vllm", "xiaomi", "zai", @@ -142,8 +143,8 @@ export function resolvePluginProviders(params: { workspaceDir: params.workspaceDir, env: params.env, onlyPluginIds: params.onlyPluginIds, - activate: params.activate, - cache: params.cache, + cache: params.cache ?? false, + activate: params.activate ?? false, logger: createPluginLoaderLogger(log), }); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 6907cafa0ed..68e4a3afe01 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -28,7 +28,7 @@ function createChatHeaderState( } = {}, ): { state: AppViewState; request: ReturnType } { let currentModel = overrides.model ?? null; - let currentModelProvider = currentModel ? "openai" : undefined; + let currentModelProvider = currentModel ? "openai" : null; const omitSessionFromList = overrides.omitSessionFromList ?? false; const catalog = overrides.models ?? [ { id: "gpt-5", name: "GPT-5", provider: "openai" }, @@ -39,7 +39,7 @@ function createChatHeaderState( const nextModel = (params.model as string | null | undefined) ?? null; if (!nextModel) { currentModel = null; - currentModelProvider = undefined; + currentModelProvider = null; } else { const normalized = nextModel.trim(); const slashIndex = normalized.indexOf("/");