From 021ef1220d01138250328a67658f8be8af69154e Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 27 Apr 2026 10:32:06 +0100 Subject: [PATCH] fix: reuse provider discovery plugin metadata --- CHANGELOG.md | 1 + .../provider-discovery.runtime.test.ts | 45 ++++++++++++++++--- src/plugins/provider-discovery.runtime.ts | 23 +++++++--- src/plugins/providers.ts | 2 + 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b71d44bbb..e4a92c99323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Plugins/capabilities: cache manifest-derived capability provider plugin IDs per config snapshot so repeated TTS, media, realtime, memory, image, video, and music provider resolution avoids redundant manifest scans. Thanks @shakkernerd. - Plugins/contracts: resolve runtime manifest-contract plugin owners from one plugin index plus manifest pass instead of rebuilding manifest metadata separately for all owners and enabled owners. Thanks @shakkernerd. - Plugins/extractors: reuse one manifest registry pass while resolving bundled document and web-content extractor plugins instead of rereading manifests for compatibility and enablement filtering. Thanks @shakkernerd. +- Plugins/providers: reuse one plugin registry snapshot and manifest registry while resolving provider discovery entries instead of rebuilding manifest metadata after provider owner discovery. Thanks @shakkernerd. - Plugins/registry: resolve lookup-table owner maps for providers, CLI backends, setup providers, command aliases, model catalogs, channel configs, and manifest contracts while preserving setup-only CLI backend ownership. Thanks @shakkernerd. - Mattermost: keep direct-message replies top-level by suppressing reply roots for DM delivery while preserving channel and group thread roots, and derive inbound chat kind from the trusted channel lookup instead of the websocket event channel type. Carries forward #60115, #55186, #72305, and #72659; refs #59758, #59981, #59791, and #57565. Thanks @vincentkoc, @jwchmodx, and @hnykda. - Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear. diff --git a/src/plugins/provider-discovery.runtime.test.ts b/src/plugins/provider-discovery.runtime.test.ts index eb8f1b6152b..d7ff4fa6116 100644 --- a/src/plugins/provider-discovery.runtime.test.ts +++ b/src/plugins/provider-discovery.runtime.test.ts @@ -3,14 +3,19 @@ import type { PluginManifestRecord } from "./manifest-registry.js"; import type { ProviderPlugin } from "./types.js"; const mocks = vi.hoisted(() => ({ - loadPluginManifestRegistry: vi.fn(), + loadPluginRegistrySnapshot: vi.fn(), + loadPluginManifestRegistryForInstalledIndex: vi.fn(), resolveDiscoveredProviderPluginIds: vi.fn(), resolvePluginProviders: vi.fn(), loadSource: vi.fn(), })); -vi.mock("./manifest-registry.js", () => ({ - loadPluginManifestRegistry: mocks.loadPluginManifestRegistry, +vi.mock("./plugin-registry.js", () => ({ + loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot, +})); + +vi.mock("./manifest-registry-installed.js", () => ({ + loadPluginManifestRegistryForInstalledIndex: mocks.loadPluginManifestRegistryForInstalledIndex, })); vi.mock("./providers.js", () => ({ @@ -77,8 +82,9 @@ function createProvider(params: { id: string; mode: "static" | "catalog" }): Pro describe("resolvePluginDiscoveryProvidersRuntime", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.loadPluginRegistrySnapshot.mockReturnValue({ plugins: [] }); mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["deepseek"]); - mocks.loadPluginManifestRegistry.mockReturnValue({ + mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({ plugins: [createManifestPlugin("deepseek")], diagnostics: [], }); @@ -110,7 +116,7 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { "kilocode", "unused", ]); - mocks.loadPluginManifestRegistry.mockReturnValue({ + mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({ plugins: [ createManifestPlugin("codex"), createManifestPlugin("deepseek"), @@ -144,6 +150,35 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { ); }); + it("shares one registry snapshot and manifest registry between provider id discovery and entry loading", () => { + const registry = { plugins: [] }; + const manifestRegistry = { + plugins: [createManifestPlugin("deepseek")], + diagnostics: [], + }; + mocks.loadPluginRegistrySnapshot.mockReturnValue(registry); + mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue(manifestRegistry); + mocks.loadSource.mockReturnValue(createProvider({ id: "deepseek", mode: "catalog" })); + + resolvePluginDiscoveryProvidersRuntime({ config: {}, env: {} as NodeJS.ProcessEnv }); + + expect(mocks.loadPluginRegistrySnapshot).toHaveBeenCalledOnce(); + expect(mocks.loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith({ + index: registry, + config: {}, + workspaceDir: undefined, + env: {}, + includeDisabled: true, + }); + expect(mocks.loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); + expect(mocks.resolveDiscoveredProviderPluginIds).toHaveBeenCalledWith( + expect.objectContaining({ + registry, + manifestRegistry, + }), + ); + }); + it("returns static-only discovery entries for callers that explicitly request them", () => { const staticProvider = createProvider({ id: "deepseek", mode: "static" }); mocks.loadSource.mockReturnValue(staticProvider); diff --git a/src/plugins/provider-discovery.runtime.ts b/src/plugins/provider-discovery.runtime.ts index 872255b33b7..6f7bcdf6499 100644 --- a/src/plugins/provider-discovery.runtime.ts +++ b/src/plugins/provider-discovery.runtime.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; -import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; +import { loadPluginRegistrySnapshot } from "./plugin-registry.js"; import { resolveDiscoveredProviderPluginIds } from "./providers.js"; import { resolvePluginProviders } from "./providers.runtime.js"; import { createPluginSourceLoader } from "./source-loader.js"; @@ -76,13 +77,21 @@ function resolveProviderDiscoveryEntryPlugins(params: { requireCompleteDiscoveryEntryCoverage?: boolean; discoveryEntriesOnly?: boolean; }): ProviderDiscoveryEntryResult { - const pluginIds = resolveDiscoveredProviderPluginIds(params); - const pluginIdSet = new Set(pluginIds); - const pluginRecords = loadPluginManifestRegistryForPluginRegistry({ - ...params, - pluginIds, + const registry = loadPluginRegistrySnapshot(params); + const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ + index: registry, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, includeDisabled: true, - }).plugins.filter((plugin) => pluginIdSet.has(plugin.id)); + }); + const pluginIds = resolveDiscoveredProviderPluginIds({ + ...params, + registry, + manifestRegistry, + }); + const pluginIdSet = new Set(pluginIds); + const pluginRecords = manifestRegistry.plugins.filter((plugin) => pluginIdSet.has(plugin.id)); const entryRecords = pluginRecords.filter((plugin) => plugin.providerDiscoverySource); const entryPluginIds = new Set(entryRecords.map((plugin) => plugin.id)); if (entryRecords.length === 0) { diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index c972c3619a5..0f4eb38fe8b 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -233,6 +233,8 @@ export function resolveDiscoveredProviderPluginIds(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; + registry?: PluginRegistrySnapshot; + manifestRegistry?: PluginManifestRegistry; onlyPluginIds?: readonly string[]; includeUntrustedWorkspacePlugins?: boolean; }): string[] {