fix(outbound): preserve channel registry during provider snapshots

This commit is contained in:
Peter Steinberger 2026-03-15 21:45:09 -07:00
parent 7a6be3d531
commit 0c2ae71366
No known key found for this signature in database
8 changed files with 48 additions and 10 deletions

View File

@ -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<Provide
let token = "";
let tokenRef: { source: "env" | "file" | "exec"; provider: string; id: string } | undefined;
if (selectedMode === "ref") {
const resolved = await promptSecretRefForOnboarding({
const resolved = await promptSecretRefForSetup({
provider: "anthropic-setup-token",
config: ctx.config,
prompter: ctx.prompter,

View File

@ -18,7 +18,10 @@ import {
pickGatewaySelfPresence,
resolveGatewayProbeAuthResolution,
} from "./status.gateway-probe.js";
import type { buildChannelsTable, collectChannelStatusIssues } from "./status.scan.runtime.js";
import type {
buildChannelsTable as buildChannelsTableFn,
collectChannelStatusIssues as collectChannelStatusIssuesFn,
} from "./status.scan.runtime.js";
import { getStatusSummary } from "./status.summary.js";
import { getUpdateCheckResult } from "./status.update.js";
@ -164,9 +167,9 @@ export type StatusScanResult = {
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | null;
gatewayReachable: boolean;
gatewaySelf: ReturnType<typeof pickGatewaySelfPresence>;
channelIssues: ChannelStatusIssues;
channelIssues: ReturnType<typeof collectChannelStatusIssuesFn>;
agentStatus: Awaited<ReturnType<typeof getAgentLocalStatuses>>;
channels: ChannelsTable;
channels: Awaited<ReturnType<typeof buildChannelsTableFn>>;
summary: Awaited<ReturnType<typeof getStatusSummary>>;
memory: MemoryStatusSnapshot | null;
memoryPlugin: MemoryPluginStatus;

View File

@ -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(() => {

View File

@ -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;
}

View File

@ -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<ChannelHandler> {
// 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) {

View File

@ -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,
}),
);
});

View File

@ -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),
});

View File

@ -28,7 +28,7 @@ function createChatHeaderState(
} = {},
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
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("/");