Files
openclaw/src/plugins/tools.optional.test.ts
Val Alexander 05c9492bff fix: reduce WebUI session latency churn (#76277) thanks @BunsDev
Reduce WebUI/Gateway latency churn by avoiding redundant session reloads, carrying session keys through transcript update events, and deferring explicit media provider discovery. Includes changelog attribution and closes the referenced runtime latency issues.
2026-05-02 18:39:06 -05:00

1332 lines
39 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
type MockRegistryToolEntry = {
pluginId: string;
optional: boolean;
source: string;
names: string[];
declaredNames?: string[];
factory: (ctx: unknown) => unknown;
};
const loadOpenClawPluginsMock = vi.fn();
const resolveRuntimePluginRegistryMock = vi.fn();
const applyPluginAutoEnableMock = vi.fn();
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: (params: unknown) => loadOpenClawPluginsMock(params),
resolveCompatibleRuntimePluginRegistry: (params: unknown) =>
resolveRuntimePluginRegistryMock(params),
resolvePluginRegistryLoadCacheKey: (params: unknown) => JSON.stringify(params),
resolveRuntimePluginRegistry: (params: unknown) => resolveRuntimePluginRegistryMock(params),
}));
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (params: unknown) => applyPluginAutoEnableMock(params),
}));
let resolvePluginTools: typeof import("./tools.js").resolvePluginTools;
let ensureStandalonePluginToolRegistryLoaded: typeof import("./tools.js").ensureStandalonePluginToolRegistryLoaded;
let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey;
let resetPluginToolFactoryCache: typeof import("./tools.js").resetPluginToolFactoryCache;
let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry;
let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest;
let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry;
let clearCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").clearCurrentPluginMetadataSnapshot;
let setCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").setCurrentPluginMetadataSnapshot;
function makeTool(name: string) {
return {
name,
description: `${name} tool`,
parameters: { type: "object", properties: {} },
async execute() {
return { content: [{ type: "text", text: "ok" }] };
},
};
}
function createContext() {
return {
config: {
plugins: {
enabled: true,
allow: ["optional-demo", "message", "multi"],
load: { paths: ["/tmp/plugin.js"] },
slots: { memory: "none" },
},
},
workspaceDir: "/tmp",
};
}
function createResolveToolsParams(params?: {
context?: ReturnType<typeof createContext> & Record<string, unknown>;
toolAllowlist?: readonly string[];
existingToolNames?: Set<string>;
env?: NodeJS.ProcessEnv;
suppressNameConflicts?: boolean;
allowGatewaySubagentBinding?: boolean;
}) {
return {
context: (params?.context ?? createContext()) as never,
...(params?.toolAllowlist ? { toolAllowlist: [...params.toolAllowlist] } : {}),
...(params?.existingToolNames ? { existingToolNames: params.existingToolNames } : {}),
...(params?.env ? { env: params.env } : {}),
...(params?.suppressNameConflicts ? { suppressNameConflicts: true } : {}),
...(params?.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}),
};
}
function createToolRegistry(entries: MockRegistryToolEntry[]) {
return {
plugins: entries.map((entry) => ({ id: entry.pluginId, status: "loaded" })),
tools: entries,
diagnostics: [] as Array<{
level: string;
pluginId: string;
source: string;
message: string;
}>,
};
}
function setRegistry(entries: MockRegistryToolEntry[]) {
const registry = createToolRegistry(entries);
loadOpenClawPluginsMock.mockReturnValue(registry);
setActivePluginRegistry?.(registry as never, "test-tool-registry", "gateway-bindable", "/tmp");
installToolManifestSnapshots({
config: createContext().config,
plugins: entries
.map((entry) => ({
id: entry.pluginId,
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: entry.declaredNames ?? entry.names,
},
}))
.filter((plugin) => plugin.contracts.tools.length > 0),
});
return registry;
}
function setMultiToolRegistry() {
return setRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["message", "other_tool"],
factory: () => [makeTool("message"), makeTool("other_tool")],
},
]);
}
function createOptionalDemoEntry(): MockRegistryToolEntry {
return {
pluginId: "optional-demo",
names: ["optional_tool"],
optional: true,
source: "/tmp/optional-demo.js",
factory: () => makeTool("optional_tool"),
};
}
function createMalformedTool(name: string) {
return {
name,
description: `${name} tool`,
inputSchema: { type: "object", properties: {} },
async execute() {
return { content: [{ type: "text", text: "bad" }] };
},
};
}
function installConsoleMethodSpy(method: "log" | "warn") {
const spy = vi.fn();
loggingState.rawConsole = {
log: method === "log" ? spy : vi.fn(),
info: vi.fn(),
warn: method === "warn" ? spy : vi.fn(),
error: vi.fn(),
};
return spy;
}
function resolveWithConflictingCoreName(options?: { suppressNameConflicts?: boolean }) {
return resolvePluginTools(
createResolveToolsParams({
existingToolNames: new Set(["message"]),
...(options?.suppressNameConflicts ? { suppressNameConflicts: true } : {}),
}),
);
}
function setOptionalDemoRegistry() {
setRegistry([createOptionalDemoEntry()]);
}
function resolveOptionalDemoTools(toolAllowlist?: readonly string[]) {
return resolvePluginTools(createResolveToolsParams({ toolAllowlist }));
}
function createAutoEnabledOptionalContext() {
const rawContext = createContext();
const autoEnabledConfig = {
...rawContext.config,
plugins: {
...rawContext.config.plugins,
entries: {
"optional-demo": { enabled: true },
},
},
};
return { rawContext, autoEnabledConfig };
}
function expectAutoEnabledOptionalLoad(autoEnabledConfig: unknown) {
expectLoaderCall({ config: autoEnabledConfig });
}
function resolveAutoEnabledOptionalDemoTools() {
setOptionalDemoRegistry();
const { rawContext, autoEnabledConfig } = createAutoEnabledOptionalContext();
installToolManifestSnapshot({
config: autoEnabledConfig,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
applyPluginAutoEnableMock.mockReturnValue({ config: autoEnabledConfig, changes: [] });
const tools = resolvePluginTools({
context: {
...rawContext,
config: rawContext.config as never,
} as never,
toolAllowlist: ["optional_tool"],
});
return { rawContext, autoEnabledConfig, tools };
}
function createOptionalDemoActiveRegistry() {
installToolManifestSnapshot({
config: createContext().config,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
const registry = {
plugins: [{ id: "optional-demo", status: "loaded" }],
tools: [createOptionalDemoEntry()],
diagnostics: [],
};
setActivePluginRegistry?.(registry as never, "test-tool-registry", "gateway-bindable", "/tmp");
return registry;
}
function installToolManifestSnapshot(params: {
config: ReturnType<typeof createContext>["config"];
env?: NodeJS.ProcessEnv;
plugin: Record<string, unknown>;
}) {
installToolManifestSnapshots({
config: params.config,
env: params.env,
plugins: [params.plugin],
});
}
function installToolManifestSnapshots(params: {
config: ReturnType<typeof createContext>["config"];
env?: NodeJS.ProcessEnv;
plugins: Record<string, unknown>[];
}) {
const plugins = params.plugins;
setCurrentPluginMetadataSnapshot(
{
policyHash: resolveInstalledPluginIndexPolicyHash(params.config),
workspaceDir: "/tmp",
index: {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "test",
generatedAtMs: 0,
installRecords: {},
plugins: [],
diagnostics: [],
},
registryDiagnostics: [],
manifestRegistry: { plugins, diagnostics: [] },
plugins,
diagnostics: [],
byPluginId: new Map(plugins.map((plugin) => [String(plugin.id), plugin])),
normalizePluginId: (id: string) => id,
owners: {
channels: new Map(),
channelConfigs: new Map(),
providers: new Map(),
modelCatalogProviders: new Map(),
cliBackends: new Map(),
setupProviders: new Map(),
commandAliases: new Map(),
contracts: new Map(),
},
metrics: {
registrySnapshotMs: 0,
manifestRegistryMs: 0,
ownerMapsMs: 0,
totalMs: 0,
indexPluginCount: 0,
manifestPluginCount: plugins.length,
},
} as never,
{ config: params.config, env: params.env ?? process.env, workspaceDir: "/tmp" },
);
}
function createXaiToolManifest() {
return {
id: "xai",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: ["xai"],
providerAuthEnvVars: {
xai: ["XAI_API_KEY"],
},
contracts: {
tools: ["x_search"],
},
toolMetadata: {
x_search: {
authSignals: [{ provider: "xai" }],
configSignals: [
{
rootPath: "plugins.entries.xai.config",
overlayPath: "webSearch",
required: ["apiKey"],
},
],
},
},
};
}
function expectResolvedToolNames(
tools: ReturnType<typeof resolvePluginTools>,
expectedToolNames: readonly string[],
) {
expect(tools.map((tool) => tool.name)).toEqual(expectedToolNames);
}
function expectLoaderCall(overrides: Record<string, unknown>) {
void overrides;
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
}
function expectSingleDiagnosticMessage(
diagnostics: Array<{ message: string }>,
messageFragment: string,
) {
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]?.message).toContain(messageFragment);
}
function expectConflictingCoreNameResolution(params: {
suppressNameConflicts?: boolean;
expectedDiagnosticFragment?: string;
}) {
const registry = setMultiToolRegistry();
const tools = resolveWithConflictingCoreName({
suppressNameConflicts: params.suppressNameConflicts,
});
expectResolvedToolNames(tools, ["other_tool"]);
if (params.expectedDiagnosticFragment) {
expectSingleDiagnosticMessage(registry.diagnostics, params.expectedDiagnosticFragment);
return;
}
expect(registry.diagnostics).toHaveLength(0);
}
describe("resolvePluginTools optional tools", () => {
beforeAll(async () => {
({
buildPluginToolMetadataKey,
ensureStandalonePluginToolRegistryLoaded,
resetPluginToolFactoryCache,
resolvePluginTools,
} = await import("./tools.js"));
({ pinActivePluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry } =
await import("./runtime.js"));
({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } =
await import("./current-plugin-metadata-snapshot.js"));
});
beforeEach(() => {
loadOpenClawPluginsMock.mockClear();
resolveRuntimePluginRegistryMock.mockReset();
resolveRuntimePluginRegistryMock.mockImplementation((params) =>
loadOpenClawPluginsMock(params),
);
applyPluginAutoEnableMock.mockReset();
applyPluginAutoEnableMock.mockImplementation(({ config }: { config: unknown }) => ({
config,
changes: [],
}));
resetPluginRuntimeStateForTest?.();
clearCurrentPluginMetadataSnapshot?.();
resetPluginToolFactoryCache?.();
});
afterEach(() => {
resetPluginRuntimeStateForTest?.();
clearCurrentPluginMetadataSnapshot?.();
resetPluginToolFactoryCache?.();
setLoggerOverride(null);
loggingState.rawConsole = null;
resetLogger();
vi.useRealTimers();
});
it("does not load plugin-owned tools whose manifest metadata has no available signal", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
env: {},
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
loadOpenClawPluginsMock.mockImplementation((params) =>
Array.isArray((params as { onlyPluginIds?: string[] }).onlyPluginIds) &&
(params as { onlyPluginIds?: string[] }).onlyPluginIds?.length === 0
? { tools: [], diagnostics: [] }
: {
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
},
);
const tools = resolvePluginTools({
context: {
...createContext(),
config,
} as never,
env: {},
});
expect(tools).toEqual([]);
expect(factory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("standalone bootstrap loads configured plugin tools before resolution", () => {
const config = createContext().config;
const registry = createToolRegistry([createOptionalDemoEntry()]);
loadOpenClawPluginsMock.mockReturnValue(registry);
installToolManifestSnapshot({
config,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
ensureStandalonePluginToolRegistryLoaded({
context: createContext() as never,
toolAllowlist: ["optional_tool"],
});
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
activate: false,
onlyPluginIds: ["optional-demo"],
toolDiscovery: true,
}),
);
});
it("does not reuse a pinned gateway registry for manifest-unavailable tools", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
env: {},
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
pinActivePluginChannelRegistry({
plugins: [{ id: "xai", status: "loaded" }],
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
} as never);
loadOpenClawPluginsMock.mockReturnValue({ tools: [], diagnostics: [] });
const tools = resolvePluginTools({
context: {
...createContext(),
config,
} as never,
env: {},
allowGatewaySubagentBinding: true,
});
expect(tools).toEqual([]);
expect(factory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("loads plugin-owned tools when manifest tool metadata has env auth evidence", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
env: { XAI_API_KEY: "test-key" },
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
setActivePluginRegistry(
{
plugins: [{ id: "xai", status: "loaded" }],
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
} as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const tools = resolvePluginTools({
context: {
...createContext(),
config,
} as never,
env: {
XAI_API_KEY: "test-key",
},
});
expectResolvedToolNames(tools, ["x_search"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("loads plugin-owned tools when manifest config signals point at configured non-env SecretRefs", () => {
const base = createContext();
const config = {
...base.config,
plugins: {
...base.config.plugins,
entries: {
xai: {
config: {
webSearch: {
apiKey: {
source: "file",
provider: "vault",
id: "/xai/tool-key",
},
},
},
},
},
},
secrets: {
providers: {
vault: {
source: "file",
path: "/tmp/openclaw-secrets.json",
mode: "json",
},
},
},
} as const;
installToolManifestSnapshot({
config,
env: {},
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
setActivePluginRegistry(
{
plugins: [{ id: "xai", status: "loaded" }],
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
} as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const tools = resolvePluginTools({
context: {
...base,
config,
} as never,
env: {},
});
expectResolvedToolNames(tools, ["x_search"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("skips optional tools without explicit allowlist", () => {
setOptionalDemoRegistry();
const tools = resolveOptionalDemoTools();
expect(tools).toHaveLength(0);
});
it("does not invoke named optional tool factories without a matching allowlist", () => {
const factory = vi.fn(() => makeTool("optional_tool"));
setRegistry([
{
pluginId: "optional-demo",
optional: true,
source: "/tmp/optional-demo.js",
names: ["optional_tool"],
factory,
},
]);
expect(resolveOptionalDemoTools()).toHaveLength(0);
expect(resolveOptionalDemoTools(["other_tool"])).toHaveLength(0);
expect(factory).not.toHaveBeenCalled();
});
it("invokes unnamed optional tool factories when a tool allowlist may match the result", () => {
const factory = vi.fn(() => makeTool("optional_tool"));
setRegistry([
{
pluginId: "optional-demo",
optional: true,
source: "/tmp/optional-demo.js",
names: [],
declaredNames: ["optional_tool"],
factory,
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(factory).toHaveBeenCalledTimes(1);
});
it.each([
{
name: "allows optional tools by tool name",
toolAllowlist: ["optional_tool"],
},
{
name: "allows optional tools via plugin id",
toolAllowlist: ["optional-demo"],
},
{
name: "allows optional tools via plugin-scoped allowlist entries",
toolAllowlist: ["optional_tool", "tavily"],
},
] as const)("$name", ({ toolAllowlist }) => {
setOptionalDemoRegistry();
const tools = resolveOptionalDemoTools(toolAllowlist);
expectResolvedToolNames(tools, ["optional_tool"]);
});
it("rejects plugin id collisions with core tool names", () => {
const registry = setRegistry([
{
pluginId: "message",
optional: false,
source: "/tmp/message.js",
names: ["optional_tool"],
factory: () => makeTool("optional_tool"),
},
]);
const tools = resolvePluginTools(
createResolveToolsParams({
existingToolNames: new Set(["message"]),
}),
);
expect(tools).toHaveLength(0);
expectSingleDiagnosticMessage(registry.diagnostics, "plugin id conflicts with core tool name");
});
it.each([
{
name: "skips conflicting tool names but keeps other tools",
expectedDiagnosticFragment: "plugin tool name conflict",
},
{
name: "suppresses conflict diagnostics when requested",
suppressNameConflicts: true,
},
] as const)("$name", ({ suppressNameConflicts, expectedDiagnosticFragment }) => {
expectConflictingCoreNameResolution({
suppressNameConflicts,
expectedDiagnosticFragment,
});
});
it.each([
{
name: "uses loaded plugin tools with an explicit env",
params: {
env: { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv,
toolAllowlist: ["optional_tool"],
},
expectedLoaderCall: {
env: { OPENCLAW_HOME: "/srv/openclaw-home" },
},
},
{
name: "uses loaded plugin tools with gateway subagent binding",
params: {
allowGatewaySubagentBinding: true,
toolAllowlist: ["optional_tool"],
},
expectedLoaderCall: {
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
},
},
])("$name", ({ params, expectedLoaderCall }) => {
setOptionalDemoRegistry();
if (params.env) {
installToolManifestSnapshot({
config: createContext().config,
env: params.env,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
}
resolvePluginTools(createResolveToolsParams(params));
expectLoaderCall(expectedLoaderCall);
});
it("skips malformed plugin tools while keeping valid sibling tools", () => {
const registry = setRegistry([
{
pluginId: "schema-bug",
optional: false,
source: "/tmp/schema-bug.js",
names: ["broken_tool", "valid_tool"],
factory: () => [createMalformedTool("broken_tool"), makeTool("valid_tool")],
},
]);
const tools = resolvePluginTools(createResolveToolsParams());
expectResolvedToolNames(tools, ["valid_tool"]);
expectSingleDiagnosticMessage(
registry.diagnostics,
"plugin tool is malformed (schema-bug): broken_tool missing parameters object",
);
});
it("warns with plugin factory timing details when a factory is slow", () => {
vi.useFakeTimers({ now: 0 });
const warnSpy = installConsoleMethodSpy("warn");
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
setRegistry([
{
pluginId: "optional-demo",
names: ["optional_tool"],
optional: true,
source: "/tmp/optional-demo.js",
factory: () => {
vi.advanceTimersByTime(1200);
return makeTool("optional_tool");
},
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(warnSpy).toHaveBeenCalledTimes(1);
const message = String(warnSpy.mock.calls[0]?.[0] ?? "");
expect(message).toContain("[trace:plugin-tools] factory timings");
expect(message).toContain("totalMs=1200");
expect(message).toContain("optional-demo:1200ms@1200ms");
expect(message).toContain("names=[optional_tool]");
expect(message).toContain("result=single");
expect(message).toContain("count=1");
});
it("emits trace factory timings below the warn threshold when trace logging is enabled", () => {
vi.useFakeTimers({ now: 0 });
const logSpy = installConsoleMethodSpy("log");
setLoggerOverride({ level: "silent", consoleLevel: "trace" });
setRegistry([
{
pluginId: "optional-demo",
names: ["optional_tool"],
optional: true,
source: "/tmp/optional-demo.js",
factory: () => {
vi.advanceTimersByTime(5);
return makeTool("optional_tool");
},
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(logSpy).toHaveBeenCalledTimes(1);
const message = String(logSpy.mock.calls[0]?.[0] ?? "");
expect(message).toContain("[trace:plugin-tools] factory timings");
expect(message).toContain("totalMs=5");
expect(message).toContain("optional-demo:5ms@5ms");
});
it("does not log plugin factory timings for fast factories without trace logging", () => {
vi.useFakeTimers({ now: 0 });
const warnSpy = installConsoleMethodSpy("warn");
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
setRegistry([
{
pluginId: "optional-demo",
names: ["optional_tool"],
optional: true,
source: "/tmp/optional-demo.js",
factory: () => {
vi.advanceTimersByTime(5);
return makeTool("optional_tool");
},
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(warnSpy).not.toHaveBeenCalled();
});
it("caches plugin tool descriptors and uses the runtime only on execution", async () => {
const factory = vi.fn((rawCtx: unknown) => {
const ctx = rawCtx as { sessionId?: string };
return {
...makeTool("cached_tool"),
async execute() {
return { content: [{ type: "text", text: ctx.sessionId ?? "missing" }] };
},
};
});
setRegistry([
{
pluginId: "cache-test",
optional: false,
source: "/tmp/cache-test.js",
names: ["cached_tool"],
factory,
},
]);
const first = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), sessionId: "same" },
}),
);
const second = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), sessionId: "same" },
}),
);
expectResolvedToolNames(first, ["cached_tool"]);
expectResolvedToolNames(second, ["cached_tool"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(second[0]).not.toBe(first[0]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
await expect(second[0]?.execute("call", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "same" }],
});
expect(factory).toHaveBeenCalledTimes(2);
});
it("reuses cached plugin tool descriptors across session identity changes", async () => {
const factory = vi.fn((rawCtx: unknown) => {
const ctx = rawCtx as { sessionId?: string };
return {
...makeTool("cached_session_tool"),
async execute() {
return { content: [{ type: "text", text: ctx.sessionId ?? "missing" }] };
},
};
});
setRegistry([
{
pluginId: "cache-session-test",
optional: false,
source: "/tmp/cache-session-test.js",
names: ["cached_session_tool"],
factory,
},
]);
const first = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
sessionId: "first-session",
sessionKey: "agent:main:first-session",
},
}),
);
const second = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
sessionId: "second-session",
sessionKey: "agent:main:second-session",
},
}),
);
expectResolvedToolNames(first, ["cached_session_tool"]);
expectResolvedToolNames(second, ["cached_session_tool"]);
expect(factory).toHaveBeenCalledTimes(1);
await expect(second[0]?.execute("call", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "second-session" }],
});
expect(factory).toHaveBeenCalledTimes(2);
});
it("does not reuse cached plugin tool descriptors across sandbox context changes", () => {
const factory = vi.fn((rawCtx: unknown) => {
const ctx = rawCtx as { sandboxed?: boolean };
return ctx.sandboxed ? null : makeTool("sandbox_sensitive_tool");
});
setRegistry([
{
pluginId: "sandbox-sensitive",
optional: false,
source: "/tmp/sandbox-sensitive.js",
names: ["sandbox_sensitive_tool"],
factory,
},
]);
const hostTools = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), sandboxed: false },
}),
);
const sandboxedTools = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), sandboxed: true },
}),
);
expectResolvedToolNames(hostTools, ["sandbox_sensitive_tool"]);
expect(sandboxedTools).toEqual([]);
expect(factory).toHaveBeenCalledTimes(2);
});
it("executes cached plugin tools registered with implicit names", async () => {
const factory = vi.fn(() => ({
...makeTool("implicit_tool"),
async execute() {
return { content: [{ type: "text", text: "implicit-ok" }] };
},
}));
setRegistry([
{
pluginId: "implicit-owner",
optional: false,
source: "/tmp/implicit-owner.js",
names: [],
declaredNames: ["implicit_tool"],
factory,
},
]);
const first = resolvePluginTools(createResolveToolsParams());
const second = resolvePluginTools(createResolveToolsParams());
expectResolvedToolNames(first, ["implicit_tool"]);
expectResolvedToolNames(second, ["implicit_tool"]);
expect(factory).toHaveBeenCalledTimes(1);
await expect(second[0]?.execute("call", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "implicit-ok" }],
});
expect(factory).toHaveBeenCalledTimes(2);
});
it("skips factory-returned tools outside the manifest tool contract", () => {
const registry = setRegistry([
{
pluginId: "dynamic-owner",
optional: false,
source: "/tmp/dynamic-owner.js",
names: ["declared_tool"],
declaredNames: ["declared_tool"],
factory: () => [makeTool("declared_tool"), makeTool("rogue_tool")],
},
]);
const tools = resolvePluginTools(createResolveToolsParams());
expectResolvedToolNames(tools, ["declared_tool"]);
expectSingleDiagnosticMessage(registry.diagnostics, "plugin tool is undeclared");
});
it("skips allowlisted optional malformed plugin tools", () => {
const registry = setRegistry([
{
pluginId: "optional-demo",
optional: true,
source: "/tmp/optional-demo.js",
names: ["optional_tool"],
factory: () => createMalformedTool("optional_tool"),
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expect(tools).toHaveLength(0);
expectSingleDiagnosticMessage(
registry.diagnostics,
"plugin tool is malformed (optional-demo): optional_tool missing parameters object",
);
});
it.each([
{
name: "loads plugin tools from the auto-enabled config snapshot",
expectedToolNames: undefined,
},
{
name: "does not reuse a cached active registry when auto-enable changes the config snapshot",
expectedToolNames: ["optional_tool"],
},
] as const)("$name", ({ expectedToolNames }) => {
const { rawContext, autoEnabledConfig, tools } = resolveAutoEnabledOptionalDemoTools();
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: rawContext.config.plugins?.allow,
load: rawContext.config.plugins?.load,
}),
}),
env: process.env,
}),
);
if (expectedToolNames) {
expectResolvedToolNames(tools, expectedToolNames);
}
expectAutoEnabledOptionalLoad(autoEnabledConfig);
});
it("reuses a compatible active registry instead of loading again", () => {
const activeRegistry = createOptionalDemoActiveRegistry();
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("reuses the gateway-bindable registry when it covers the tool runtime scope", () => {
const activeRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("does not widen active registry reuse to non-matching plugin tool owners", () => {
installToolManifestSnapshot({
config: createContext().config,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
const heavyFactory = vi.fn(() => makeTool("heavy_tool"));
const activeRegistry = {
plugins: [
{ id: "optional-demo", status: "loaded" },
{ id: "heavy-startup", status: "loaded" },
],
tools: [
createOptionalDemoEntry(),
{
pluginId: "heavy-startup",
optional: false,
source: "/tmp/heavy-startup.js",
names: ["heavy_tool"],
factory: heavyFactory,
},
],
diagnostics: [],
};
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(heavyFactory).not.toHaveBeenCalled();
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("adds enabled non-startup tool plugins to the active tool runtime scope", () => {
const activeRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
resolvePluginTools({
context: {
...createContext(),
config: {
plugins: {
enabled: true,
allow: ["tavily"],
entries: {
tavily: { enabled: true },
},
},
},
} as never,
toolAllowlist: ["optional_tool", "tavily"],
allowGatewaySubagentBinding: true,
});
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
});
it("reuses the pinned gateway channel registry after provider runtime loads replace active registry", () => {
const gatewayRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(
gatewayRegistry as never,
"gateway-startup",
"gateway-bindable",
"/tmp",
);
pinActivePluginChannelRegistry(gatewayRegistry as never);
setActivePluginRegistry(
{
plugins: [],
tools: [],
diagnostics: [],
} as never,
"provider-runtime",
"default",
"/tmp",
);
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("reuses the pinned gateway channel registry even when the caller omits gateway binding", () => {
const gatewayRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(
gatewayRegistry as never,
"gateway-startup",
"gateway-bindable",
"/tmp",
);
pinActivePluginChannelRegistry(gatewayRegistry as never);
setActivePluginRegistry(
{
plugins: [],
tools: [],
diagnostics: [],
} as never,
"provider-runtime",
"default",
"/tmp",
);
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("loads plugin tools when gateway-bindable tool loads have no active registry", () => {
setOptionalDemoRegistry();
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expectLoaderCall({
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
});
it("reloads when gateway binding would otherwise reuse a default-mode active registry", () => {
setActivePluginRegistry(
{
plugins: [],
tools: [],
diagnostics: [],
} as never,
"default-registry",
"default",
);
setOptionalDemoRegistry();
resolvePluginTools({
context: createContext() as never,
allowGatewaySubagentBinding: true,
toolAllowlist: ["optional_tool"],
});
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
});
describe("buildPluginToolMetadataKey", () => {
beforeAll(async () => {
({ buildPluginToolMetadataKey } = await import("./tools.js"));
});
it("does not collide when ids or names contain separator-like characters", () => {
expect(buildPluginToolMetadataKey("plugin", "a\uE000b")).not.toBe(
buildPluginToolMetadataKey("plugin\uE000a", "b"),
);
expect(buildPluginToolMetadataKey("plugin", "a\u0000b")).not.toBe(
buildPluginToolMetadataKey("plugin\u0000a", "b"),
);
});
});