fix(plugins): preserve optional tool metadata

This commit is contained in:
Vincent Koc
2026-05-03 21:06:26 -07:00
parent 3f732aee83
commit 09e7eb6687
3 changed files with 85 additions and 2 deletions

View File

@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/tools: mark manifest-optional sibling tools as optional even when they come from a shared non-optional factory, so cached/status/MCP metadata keeps opt-in tool policy accurate. Thanks @vincentkoc.
- Matrix: keep `streaming.progress.toolProgress` scoped to progress draft mode, so partial and quiet Matrix previews do not lose tool progress unless `streaming.preview.toolProgress` is disabled. Thanks @vincentkoc.
- Channels/streaming: keep `streaming.progress.toolProgress` scoped to progress draft mode, so disabling compact progress lines does not silence partial/block preview tool updates. Thanks @vincentkoc.
- Plugins/update: treat OpenClaw stable correction versions like `2026.5.3-1` as stable releases for npm installs, plugin updates, and bundled-version comparisons, so `latest` can advance official plugins without prerelease opt-in. Thanks @vincentkoc.

View File

@@ -32,6 +32,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({
let resolvePluginTools: typeof import("./tools.js").resolvePluginTools;
let ensureStandalonePluginToolRegistryLoaded: typeof import("./tools.js").ensureStandalonePluginToolRegistryLoaded;
let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey;
let getPluginToolMeta: typeof import("./tools.js").getPluginToolMeta;
let resetPluginToolFactoryCache: typeof import("./tools.js").resetPluginToolFactoryCache;
let getActivePluginRegistry: typeof import("./runtime.js").getActivePluginRegistry;
let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry;
@@ -410,6 +411,7 @@ describe("resolvePluginTools optional tools", () => {
({
buildPluginToolMetadataKey,
ensureStandalonePluginToolRegistryLoaded,
getPluginToolMeta,
resetPluginToolFactoryCache,
resolvePluginTools,
} = await import("./tools.js"));
@@ -1270,6 +1272,70 @@ describe("resolvePluginTools optional tools", () => {
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("marks allowlisted manifest-optional sibling tools from non-optional factories as optional", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
plugin: {
id: "multi",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["other_tool", "optional_tool"],
},
toolMetadata: {
optional_tool: {
optional: true,
},
},
},
});
const factory = vi.fn(() => [makeTool("other_tool"), makeTool("optional_tool")]);
setActivePluginRegistry(
createToolRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool", "optional_tool"],
declaredNames: ["other_tool", "optional_tool"],
factory,
},
]) as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const first = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
config,
},
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
}),
);
const second = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
config,
},
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
}),
);
expectResolvedToolNames(first, ["other_tool", "optional_tool"]);
expectResolvedToolNames(second, ["other_tool", "optional_tool"]);
expect(getPluginToolMeta(first[0])?.optional).toBe(false);
expect(getPluginToolMeta(first[1])?.optional).toBe(true);
expect(getPluginToolMeta(second[1])?.optional).toBe(true);
expect(factory).toHaveBeenCalledTimes(1);
});
it("rejects plugin id collisions with core tool names", () => {
const registry = setRegistry([
{

View File

@@ -128,6 +128,17 @@ function isManifestToolOptional(plugin: PluginManifestRecord, toolName: string):
return plugin.toolMetadata?.[toolName]?.optional === true;
}
function isPluginToolOptional(params: {
entry: PluginToolRegistration;
manifestPlugin: PluginManifestRecord | undefined;
toolName: string;
}): boolean {
return (
params.entry.optional ||
(params.manifestPlugin ? isManifestToolOptional(params.manifestPlugin, params.toolName) : false)
);
}
function isOptionalToolAllowed(params: {
toolName: string;
pluginId: string;
@@ -1162,9 +1173,14 @@ export function resolvePluginTools(params: {
normalizedNameSet.add(normalizedToolName);
existing.add(tool.name);
existingNormalized.add(normalizedToolName);
const optional = isPluginToolOptional({
entry,
manifestPlugin,
toolName: tool.name,
});
pluginToolMeta.set(tool, {
pluginId: entry.pluginId,
optional: entry.optional,
optional,
});
if (manifestPlugin) {
const capturedDescriptors = capturedDescriptorsByPluginId.get(entry.pluginId) ?? [];
@@ -1172,7 +1188,7 @@ export function resolvePluginTools(params: {
capturePluginToolDescriptor({
pluginId: entry.pluginId,
tool,
optional: entry.optional,
optional,
}),
);
capturedDescriptorsByPluginId.set(entry.pluginId, capturedDescriptors);