refactor: split manifest command alias helpers

This commit is contained in:
Peter Steinberger
2026-04-10 17:34:29 +01:00
parent 5f3356a746
commit 777c6f7580
13 changed files with 312 additions and 119 deletions

View File

@@ -71,12 +71,12 @@ function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) {
};
}
const waitFor = async (predicate: () => boolean, timeoutMs = 3_000) => {
const waitFor = async (label: string, predicate: () => boolean, timeoutMs = 30_000) => {
await vi.waitFor(
() => {
expect(predicate()).toBe(true);
expect(predicate(), label).toBe(true);
},
{ timeout: timeoutMs, interval: 8 },
{ timeout: timeoutMs, interval: 20 },
);
};
@@ -132,7 +132,7 @@ async function emitLifecycleEndAndFlush(params: {
}
async function waitForRunCleanup(childSessionKey: string) {
await waitFor(() => {
await waitFor("run cleanup bookkeeping", () => {
const run = getLatestSubagentRunByChildSessionKey(childSessionKey);
return run?.cleanupCompletedAt != null;
});
@@ -213,6 +213,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
throw new Error("missing child runId");
}
await waitFor(
"subagent wait, label patch, and main agent trigger",
() =>
ctx.waitCalls.some((call) => call.runId === child.runId) &&
patchCalls.some((call) => call.label === "my-task") &&
@@ -275,6 +276,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
});
await waitFor(
"lifecycle cleanup",
() => ctx.calls.filter((call) => call.method === "agent").length >= 2 && Boolean(deletedKey),
);
@@ -336,12 +338,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
if (!child.runId) {
throw new Error("missing child runId");
}
await waitFor(
() =>
ctx.waitCalls.some((call) => call.runId === child.runId) &&
ctx.calls.filter((call) => call.method === "agent").length >= 2 &&
Boolean(deletedKey),
await waitFor("agent.wait called for child run", () =>
ctx.waitCalls.some((call) => call.runId === child.runId),
);
await waitFor(
"main agent cleanup trigger",
() => ctx.calls.filter((call) => call.method === "agent").length >= 2,
);
await waitFor("delete cleanup", () => Boolean(deletedKey));
const childWait = ctx.waitCalls.find((call) => call.runId === child.runId);
expect(childWait?.timeoutMs).toBe(1000);
@@ -392,12 +396,13 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
}
const childSessionKey = child.sessionKey;
await waitFor(() => {
return (
await waitFor(
"timeout outcome",
() =>
ctx.waitCalls.some((call) => call.runId === child.runId) &&
getLatestSubagentRunByChildSessionKey(childSessionKey)?.outcome?.status === "timeout"
);
}, 20_000);
getLatestSubagentRunByChildSessionKey(childSessionKey)?.outcome?.status === "timeout",
20_000,
);
await waitForRunCleanup(childSessionKey);
const childWait = ctx.waitCalls.find((call) => call.runId === child.runId);
@@ -427,7 +432,16 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
if (!child.sessionKey) {
throw new Error("missing child sessionKey");
}
await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2);
await emitLifecycleEndAndFlush({
runId: child.runId,
startedAt: 1000,
endedAt: 2000,
});
await waitFor(
"account-aware lifecycle announce",
() => ctx.calls.filter((call) => call.method === "agent").length >= 2,
);
await waitForRunCleanup(child.sessionKey);
const agentCalls = ctx.calls.filter((call) => call.method === "agent");

View File

@@ -11,7 +11,7 @@ import { isMainModule } from "../infra/is-main.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { enableConsoleCapture } from "../logging.js";
import { resolveManifestCommandAliasOwner } from "../plugins/manifest-registry.js";
import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js";
import { hasMemoryRuntime } from "../plugins/memory-state.js";
import {
normalizeLowercaseStringOrEmpty,

View File

@@ -12,6 +12,10 @@ vi.mock("../plugin-sdk/telegram-command-config.js", () => ({
resolveTelegramCustomCommands: () => ({ commands: [], issues: [] }),
}));
vi.mock("../plugins/manifest-command-aliases.runtime.js", () => ({
resolveManifestCommandAliasOwner: () => undefined,
}));
const getScopedWebSearchCredential = (key: string) => (search?: Record<string, unknown>) =>
(search?.[key] as { apiKey?: unknown } | undefined)?.apiKey;
const getConfiguredPluginWebSearchConfig =
@@ -218,7 +222,6 @@ vi.mock("../plugins/manifest-registry.js", () => {
params?.contract === "webSearchProviders"
? mockWebSearchProviders.find((provider) => provider.id === params.value)?.pluginId
: undefined,
resolveManifestCommandAliasOwner: () => undefined,
};
});

View File

@@ -11,9 +11,9 @@ import {
collectRelevantDoctorPluginIds,
listPluginDoctorLegacyConfigRules,
} from "../plugins/doctor-contract-registry.js";
import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js";
import {
loadPluginManifestRegistry,
resolveManifestCommandAliasOwner,
resolveManifestContractPluginIds,
} from "../plugins/manifest-registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";

View File

@@ -0,0 +1,26 @@
import type { OpenClawConfig } from "../config/config.js";
import {
resolveManifestCommandAliasOwnerInRegistry,
type PluginManifestCommandAliasRecord,
} from "./manifest-command-aliases.js";
import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js";
export function resolveManifestCommandAliasOwner(params: {
command: string | undefined;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
registry?: PluginManifestRegistry;
}): PluginManifestCommandAliasRecord | undefined {
const registry =
params.registry ??
loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
return resolveManifestCommandAliasOwnerInRegistry({
command: params.command,
registry,
});
}

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
normalizeManifestCommandAliases,
resolveManifestCommandAliasOwnerInRegistry,
} from "./manifest-command-aliases.js";
describe("manifest command aliases", () => {
it("normalizes string and object entries", () => {
expect(
normalizeManifestCommandAliases([
"memory",
{ name: "reindex", kind: "runtime-slash", cliCommand: "memory" },
{ name: "" },
{ name: "bad-kind", kind: "unknown" },
]),
).toEqual([
{ name: "memory" },
{ name: "reindex", kind: "runtime-slash", cliCommand: "memory" },
{ name: "bad-kind" },
]);
});
it("resolves aliases without treating plugin ids as command aliases", () => {
const registry = {
plugins: [
{
id: "memory-core",
commandAliases: [{ name: "memory", kind: "runtime-slash" as const }],
},
{
id: "memory",
commandAliases: [{ name: "legacy-memory" }],
},
],
};
expect(resolveManifestCommandAliasOwnerInRegistry({ command: "memory", registry })).toBe(
undefined,
);
expect(
resolveManifestCommandAliasOwnerInRegistry({ command: "legacy-memory", registry }),
).toMatchObject({
pluginId: "memory",
name: "legacy-memory",
});
});
});

View File

@@ -0,0 +1,88 @@
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { isRecord } from "../utils.js";
export type PluginManifestCommandAliasKind = "runtime-slash";
export type PluginManifestCommandAlias = {
/** Command-like name users may put in plugin config by mistake. */
name: string;
/** Command family, used for targeted diagnostics. */
kind?: PluginManifestCommandAliasKind;
/** Optional root CLI command that handles related CLI operations. */
cliCommand?: string;
};
export type PluginManifestCommandAliasRecord = PluginManifestCommandAlias & {
pluginId: string;
};
export type PluginManifestCommandAliasRegistry = {
plugins: readonly {
id: string;
commandAliases?: readonly PluginManifestCommandAlias[];
}[];
};
export function normalizeManifestCommandAliases(
value: unknown,
): PluginManifestCommandAlias[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const normalized: PluginManifestCommandAlias[] = [];
for (const entry of value) {
if (typeof entry === "string") {
const name = normalizeOptionalString(entry) ?? "";
if (name) {
normalized.push({ name });
}
continue;
}
if (!isRecord(entry)) {
continue;
}
const name = normalizeOptionalString(entry.name) ?? "";
if (!name) {
continue;
}
const kind = entry.kind === "runtime-slash" ? entry.kind : undefined;
const cliCommand = normalizeOptionalString(entry.cliCommand) ?? "";
normalized.push({
name,
...(kind ? { kind } : {}),
...(cliCommand ? { cliCommand } : {}),
});
}
return normalized.length > 0 ? normalized : undefined;
}
export function resolveManifestCommandAliasOwnerInRegistry(params: {
command: string | undefined;
registry: PluginManifestCommandAliasRegistry;
}): PluginManifestCommandAliasRecord | undefined {
const normalizedCommand = normalizeOptionalLowercaseString(params.command);
if (!normalizedCommand) {
return undefined;
}
const commandIsPluginId = params.registry.plugins.some(
(plugin) => normalizeOptionalLowercaseString(plugin.id) === normalizedCommand,
);
if (commandIsPluginId) {
return undefined;
}
for (const plugin of params.registry.plugins) {
const alias = plugin.commandAliases?.find(
(entry) => normalizeOptionalLowercaseString(entry.name) === normalizedCommand,
);
if (alias) {
return { ...alias, pluginId: plugin.id };
}
}
return undefined;
}

View File

@@ -14,10 +14,10 @@ import {
type NormalizedPluginsConfig,
} from "./config-policy.js";
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js";
import {
loadPluginManifest,
type OpenClawPackageManifest,
type PluginManifestCommandAlias,
type PluginManifestConfigContracts,
type PluginManifest,
type PluginManifestChannelConfig,
@@ -206,47 +206,6 @@ export function resolveManifestContractOwnerPluginId(params: {
)?.id;
}
export type PluginManifestCommandAliasRecord = PluginManifestCommandAlias & {
pluginId: string;
};
export function resolveManifestCommandAliasOwner(params: {
command: string | undefined;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
registry?: PluginManifestRegistry;
}): PluginManifestCommandAliasRecord | undefined {
const normalizedCommand = normalizeOptionalLowercaseString(params.command);
if (!normalizedCommand) {
return undefined;
}
const registry =
params.registry ??
loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const commandIsPluginId = registry.plugins.some(
(plugin) => normalizeOptionalLowercaseString(plugin.id) === normalizedCommand,
);
if (commandIsPluginId) {
return undefined;
}
for (const plugin of registry.plugins) {
const alias = plugin.commandAliases?.find(
(entry) => normalizeOptionalLowercaseString(entry.name) === normalizedCommand,
);
if (alias) {
return { ...alias, pluginId: plugin.id };
}
}
return undefined;
}
function resolveManifestCacheMs(env: NodeJS.ProcessEnv): number {
const raw = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim();
if (raw === "" || raw === "0") {

View File

@@ -7,6 +7,10 @@ import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/bou
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeTrimmedStringList } from "../shared/string-normalization.js";
import { isRecord } from "../utils.js";
import {
normalizeManifestCommandAliases,
type PluginManifestCommandAlias,
} from "./manifest-command-aliases.js";
import type { PluginConfigUiHint, PluginKind } from "./types.js";
export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json";
@@ -34,17 +38,6 @@ export type PluginManifestModelSupport = {
modelPatterns?: string[];
};
export type PluginManifestCommandAliasKind = "runtime-slash";
export type PluginManifestCommandAlias = {
/** Command-like name users may put in plugin config by mistake. */
name: string;
/** Command family, used for targeted diagnostics. */
kind?: PluginManifestCommandAliasKind;
/** Optional root CLI command that handles related CLI operations. */
cliCommand?: string;
};
export type PluginManifestConfigLiteral = string | number | boolean | null;
export type PluginManifestDangerousConfigFlag = {
@@ -373,38 +366,6 @@ function normalizeManifestModelSupport(value: unknown): PluginManifestModelSuppo
return Object.keys(modelSupport).length > 0 ? modelSupport : undefined;
}
function normalizeManifestCommandAliases(value: unknown): PluginManifestCommandAlias[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const normalized: PluginManifestCommandAlias[] = [];
for (const entry of value) {
if (typeof entry === "string") {
const name = normalizeOptionalString(entry) ?? "";
if (name) {
normalized.push({ name });
}
continue;
}
if (!isRecord(entry)) {
continue;
}
const name = normalizeOptionalString(entry.name) ?? "";
if (!name) {
continue;
}
const kind = entry.kind === "runtime-slash" ? entry.kind : undefined;
const cliCommand = normalizeOptionalString(entry.cliCommand) ?? "";
normalized.push({
name,
...(kind ? { kind } : {}),
...(cliCommand ? { cliCommand } : {}),
});
}
return normalized.length > 0 ? normalized : undefined;
}
function normalizeProviderAuthChoices(
value: unknown,
): PluginManifestProviderAuthChoice[] | undefined {

View File

@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const loadPluginManifestRegistryMock = vi.hoisted(() => vi.fn());
vi.mock("./manifest-registry.js", () => ({
loadPluginManifestRegistry: loadPluginManifestRegistryMock,
}));
afterEach(() => {
loadPluginManifestRegistryMock.mockReset();
});
describe("setup-registry runtime fallback", () => {
it("uses bundled manifest cliBackends when the runtime registry has no match", async () => {
loadPluginManifestRegistryMock.mockReturnValue({
diagnostics: [],
plugins: [
{
id: "openai",
origin: "bundled",
cliBackends: ["Codex-CLI"],
},
{
id: "local",
origin: "workspace",
cliBackends: ["local-cli"],
},
],
});
const { __testing, resolvePluginSetupCliBackendRuntime } =
await import("./setup-registry.runtime.js");
__testing.resetRuntimeState();
__testing.setRuntimeModuleForTest({
resolvePluginSetupCliBackend: () => undefined,
});
expect(resolvePluginSetupCliBackendRuntime({ backend: "codex-cli" })).toEqual({
pluginId: "openai",
backend: { id: "Codex-CLI" },
});
expect(resolvePluginSetupCliBackendRuntime({ backend: "local-cli" })).toBeUndefined();
expect(loadPluginManifestRegistryMock).toHaveBeenCalledTimes(1);
expect(loadPluginManifestRegistryMock).toHaveBeenCalledWith({ cache: true });
});
});

View File

@@ -20,6 +20,16 @@ const SETUP_REGISTRY_RUNTIME_CANDIDATES = ["./setup-registry.js", "./setup-regis
let setupRegistryRuntimeModule: SetupRegistryRuntimeModule | undefined;
let bundledSetupCliBackendsCache: SetupCliBackendRuntimeEntry[] | undefined;
export const __testing = {
resetRuntimeState(): void {
setupRegistryRuntimeModule = undefined;
bundledSetupCliBackendsCache = undefined;
},
setRuntimeModuleForTest(module: SetupRegistryRuntimeModule | undefined): void {
setupRegistryRuntimeModule = module;
},
};
function resolveBundledSetupCliBackends(): SetupCliBackendRuntimeEntry[] {
if (bundledSetupCliBackendsCache) {
return bundledSetupCliBackendsCache;