fix: stabilize plugin discovery and session message tests

This commit is contained in:
Peter Steinberger
2026-05-02 03:24:45 +01:00
parent 9b13616240
commit fe5faaacc3
5 changed files with 145 additions and 60 deletions

View File

@@ -1,10 +1,6 @@
import { z, type ZodType } from "zod";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import {
resolveSingleAccountKeysToMove,
resolveSingleAccountPromotionTarget,
} from "./setup-promotion-helpers.js";
import type { ChannelSetupAdapter } from "./types.adapters.js";
import type { ChannelSetupInput } from "./types.core.js";
@@ -14,6 +10,81 @@ type ChannelSectionBase = {
accounts?: Record<string, Record<string, unknown>>;
};
const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([
"name",
"token",
"tokenFile",
"botToken",
"appToken",
"account",
"signalNumber",
"authDir",
"cliPath",
"dbPath",
"httpUrl",
"httpHost",
"httpPort",
"webhookPath",
"webhookUrl",
"webhookSecret",
"service",
"region",
"homeserver",
"userId",
"accessToken",
"password",
"deviceName",
"url",
"code",
"dmPolicy",
"allowFrom",
"groupPolicy",
"groupAllowFrom",
"defaultTo",
"streaming",
"deviceId",
"avatarUrl",
"initialSyncLimit",
"encryption",
"allowlistOnly",
"allowBots",
"blockStreaming",
"replyToMode",
"threadReplies",
"textChunkLimit",
"chunkMode",
"responsePrefix",
"ackReaction",
"ackReactionScope",
"reactionNotifications",
"threadBindings",
"startupVerification",
"startupVerificationCooldownHours",
"mediaMaxMb",
"autoJoin",
"autoJoinAllowlist",
"dm",
"groups",
"rooms",
"actions",
]);
const NAMED_ACCOUNT_PROMOTION_KEYS_BY_CHANNEL: Record<string, readonly string[]> = {
matrix: [
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"avatarUrl",
"initialSyncLimit",
"encryption",
],
telegram: ["botToken", "tokenFile"],
};
function channelHasAccounts(cfg: OpenClawConfig, channelKey: string): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[channelKey] as ChannelSectionBase | undefined;
@@ -427,6 +498,46 @@ function resolveExistingAccountKey(
return targetAccountId;
}
function resolveSingleAccountKeysToMove(params: {
channelKey: string;
channel: Record<string, unknown>;
}): string[] {
const hasNamedAccounts = Object.keys(
(params.channel.accounts as Record<string, unknown>) ?? {},
).some(Boolean);
const entries = Object.entries(params.channel)
.filter(
([key, value]) =>
key !== "accounts" && key !== "defaultAccount" && key !== "enabled" && value !== undefined,
)
.map(([key]) => key);
const keysToMove = entries.filter((key) => COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE.has(key));
if (!hasNamedAccounts || keysToMove.length === 0) {
return keysToMove;
}
const namedAccountPromotionKeys = NAMED_ACCOUNT_PROMOTION_KEYS_BY_CHANNEL[params.channelKey];
return namedAccountPromotionKeys
? keysToMove.filter((key) => namedAccountPromotionKeys.includes(key))
: keysToMove;
}
function resolveSingleAccountPromotionTarget(params: { channel: ChannelSectionBase }): string {
const accounts = params.channel.accounts ?? {};
const normalizedDefaultAccount =
typeof params.channel.defaultAccount === "string" && params.channel.defaultAccount.trim()
? normalizeAccountId(params.channel.defaultAccount)
: undefined;
if (normalizedDefaultAccount) {
return (
Object.keys(accounts).find(
(accountId) => normalizeAccountId(accountId) === normalizedDefaultAccount,
) ?? DEFAULT_ACCOUNT_ID
);
}
const namedAccounts = Object.keys(accounts).filter(Boolean);
return namedAccounts.length === 1 ? namedAccounts[0] : DEFAULT_ACCOUNT_ID;
}
// When promoting a single-account channel config to multi-account,
// move top-level account settings into accounts.default so the original
// account keeps working without duplicate account values at channel root.
@@ -453,7 +564,6 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: {
}
const targetAccountId = resolveSingleAccountPromotionTarget({
channelKey: params.channelKey,
channel: base,
});
const resolvedTargetAccountKey = resolveExistingAccountKey(accounts, targetAccountId);

View File

@@ -35,6 +35,14 @@ vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: mockLoadPluginManifestRegistry,
}));
vi.mock("../plugins/plugin-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/plugin-registry.js")>();
return {
...actual,
loadPluginManifestRegistryForPluginRegistry: mockLoadPluginManifestRegistry,
};
});
vi.mock("../plugins/doctor-contract-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/doctor-contract-registry.js")>();
return {
@@ -804,7 +812,7 @@ describe("config io write", () => {
});
});
it("preserves parsed source config when snapshot validation throws", async () => {
it("preserves parsed source config when snapshot validation fails", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -814,10 +822,6 @@ describe("config io write", () => {
};
const originalRaw = `${JSON.stringify(original, null, 2)}\n`;
await fs.writeFile(configPath, originalRaw, "utf-8");
mockLoadPluginManifestRegistry.mockImplementationOnce(() => {
throw new Error("manifest registry unavailable");
});
const io = createFastConfigIO(home);
const snapshot = await io.readConfigFileSnapshot();
@@ -827,7 +831,7 @@ describe("config io write", () => {
expect(snapshot.parsed).toEqual(original);
expect(snapshot.sourceConfig).toEqual(original);
expect(snapshot.config).toEqual(original);
expect(snapshot.issues[0]?.message).toContain("manifest registry unavailable");
expect(snapshot.issues[0]?.message).toContain("unknown channel id: test-plugin-channel");
});
});

View File

@@ -155,7 +155,7 @@ describe("normalizePluginsConfig", () => {
expect(result.entries.minimax?.enabled).toBe(false);
});
it("reuses the plugin alias discovery during one config normalization", async () => {
it("normalizes unknown plugin ids without loading discovery", async () => {
vi.resetModules();
const discovery = await import("./discovery.js");
const discoverPlugins = vi.spyOn(discovery, "discoverOpenClawPlugins");
@@ -176,10 +176,10 @@ describe("normalizePluginsConfig", () => {
expect(result.allow).toEqual(["unknown-plugin-one", "unknown-plugin-two"]);
expect(result.deny).toEqual(["unknown-plugin-three"]);
expect(result.entries["unknown-plugin-four"]?.enabled).toBe(true);
expect(discoverPlugins).toHaveBeenCalledTimes(1);
expect(discoverPlugins).not.toHaveBeenCalled();
});
it("keeps alias lookup limited to bundled plugin manifests", async () => {
it("does not load discovery or manifests for alias lookup", async () => {
vi.resetModules();
const discovery = await import("./discovery.js");
const manifest = await import("./manifest.js");
@@ -224,7 +224,7 @@ describe("normalizePluginsConfig", () => {
});
expect(result.deny).toEqual(["anthropic"]);
expect(discoverPlugins).toHaveBeenCalledTimes(1);
expect(discoverPlugins).not.toHaveBeenCalled();
expect(loadManifest).not.toHaveBeenCalled();
});
});

View File

@@ -20,8 +20,6 @@ import {
type NormalizePluginId,
type NormalizedPluginsConfig as SharedNormalizedPluginsConfig,
} from "./config-normalization-shared.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { loadPluginManifest } from "./manifest.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import { defaultSlotIdForKey } from "./slots.js";
@@ -48,34 +46,6 @@ const BUILT_IN_PLUGIN_ALIAS_LOOKUP = new Map<string, string>([
function getBundledPluginAliasLookup(): ReadonlyMap<string, string> {
const lookup = new Map<string, string>();
for (const candidate of discoverOpenClawPlugins({}).candidates) {
if (candidate.origin !== "bundled") {
continue;
}
const manifestResult = candidate.bundledManifest
? { ok: true as const, manifest: candidate.bundledManifest }
: loadPluginManifest(candidate.rootDir, false);
if (!manifestResult.ok) {
continue;
}
const manifest = manifestResult.manifest;
const pluginId = normalizeOptionalLowercaseString(manifest.id);
if (pluginId) {
lookup.set(pluginId, manifest.id);
}
for (const providerId of manifest.providers ?? []) {
const normalizedProviderId = normalizeOptionalLowercaseString(providerId);
if (normalizedProviderId) {
lookup.set(normalizedProviderId, manifest.id);
}
}
for (const legacyPluginId of manifest.legacyPluginIds ?? []) {
const normalizedLegacyPluginId = normalizeOptionalLowercaseString(legacyPluginId);
if (normalizedLegacyPluginId) {
lookup.set(normalizedLegacyPluginId, manifest.id);
}
}
}
for (const [alias, pluginId] of BUILT_IN_PLUGIN_ALIAS_FALLBACKS) {
lookup.set(alias, pluginId);
}

View File

@@ -1178,8 +1178,22 @@ function activatePluginRegistry(
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
const requestedOnlyPluginIds = normalizePluginIdScope(options.onlyPluginIds);
const requestedOnlyPluginIdSet = createPluginIdScopeSet(requestedOnlyPluginIds);
if (options.activate === false && requestedOnlyPluginIdSet?.size === 0) {
return createEmptyPluginRegistry();
if (requestedOnlyPluginIdSet && requestedOnlyPluginIdSet.size === 0) {
const emptyRegistry = createEmptyPluginRegistry();
if (options.activate !== false) {
clearAgentHarnesses();
clearPluginCommands();
clearPluginInteractiveHandlers();
clearDetachedTaskLifecycleRuntimeRegistration();
clearMemoryPluginState();
activatePluginRegistry(
emptyRegistry,
`empty-plugin-scope::${resolveRuntimeSubagentMode(options.runtimeOptions)}::${options.workspaceDir ?? ""}`,
resolveRuntimeSubagentMode(options.runtimeOptions),
options.workspaceDir,
);
}
return emptyRegistry;
}
const {
@@ -1203,19 +1217,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const validateOnly = options.mode === "validate";
const onlyPluginIdSet = createPluginIdScopeSet(onlyPluginIds);
if (onlyPluginIdSet && onlyPluginIdSet.size === 0) {
const emptyRegistry = createEmptyPluginRegistry();
if (shouldActivate) {
clearAgentHarnesses();
clearPluginCommands();
clearPluginInteractiveHandlers();
clearDetachedTaskLifecycleRuntimeRegistration();
clearMemoryPluginState();
activatePluginRegistry(emptyRegistry, cacheKey, runtimeSubagentMode, options.workspaceDir);
}
return emptyRegistry;
}
const cacheEnabled = options.cache !== false;
if (cacheEnabled) {
const cached = getCachedPluginRegistry(cacheKey);