Files
openclaw/src/plugins/web-provider-runtime-shared.test.ts
Joey Krug c76d8f5a7c fix(web-search): keep first-class web_search runtime providers visible
When createWebSearchTool is wired with lateBindRuntimeConfig: true, the
first-class assistant tool now lives off whatever runtime is active at
execute time. That works in the gateway process where runtime metadata
and the active secrets snapshot are populated, but in agent contexts that
do not share that in-process state, both fall through to undefined and
the tool returned "web_search is disabled or no provider is available"
even though `openclaw capability web search` and direct provider runtime
execution succeeded.

Two fixes:

- src/agents/tools/web-search.ts: when late-binding, fall back to
  options.runtimeWebSearch when the active runtime web tools metadata is
  null, and fall back to options.config when getActiveSecretsRuntimeSnapshot
  is null. Derive a configured provider id from
  config.tools.web.search.provider and use it together with the runtime
  selection when deciding preferRuntimeProviders, so an explicit Brave/
  Perplexity selection still discovers the configured plugin even when
  no runtime provider id is bound.
- src/plugins/web-provider-runtime-shared.ts: the active gateway plugin
  registry may be otherwise compatible with the active config while
  contributing zero web providers (channels, memory, harnesses, and
  sidecars without Brave/web). Treating that empty active registry as
  authoritative meant first-class tools resolved to "no provider".
  Fall through to the scoped provider plugin load when the active
  registry returns no providers. Explicit `onlyPluginIds: []` still
  short-circuits to [] to preserve the empty-scope contract.

Adds regression tests for both seams.
2026-05-04 07:18:10 +01:00

480 lines
15 KiB
TypeScript

import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
isPluginRegistryLoadInFlight: vi.fn(() => false),
loadOpenClawPlugins: vi.fn(),
resolveCompatibleRuntimePluginRegistry: vi.fn(),
getLoadedRuntimePluginRegistry: vi.fn(),
resolvePluginRegistryLoadCacheKey: vi.fn((options: unknown) => JSON.stringify(options)),
resolveRuntimePluginRegistry: vi.fn(),
getActivePluginRegistry: vi.fn<() => Record<string, unknown> | null>(() => null),
getActivePluginRegistryWorkspaceDir: vi.fn(() => undefined),
buildPluginRuntimeLoadOptionsFromValues: vi.fn(
(_values: unknown, overrides?: Record<string, unknown>) => ({
...overrides,
}),
),
createPluginRuntimeLoaderLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
}));
vi.mock("./loader.js", () => ({
isPluginRegistryLoadInFlight: mocks.isPluginRegistryLoadInFlight,
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
resolveCompatibleRuntimePluginRegistry: mocks.resolveCompatibleRuntimePluginRegistry,
resolvePluginRegistryLoadCacheKey: mocks.resolvePluginRegistryLoadCacheKey,
resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry,
}));
vi.mock("./active-runtime-registry.js", () => ({
getLoadedRuntimePluginRegistry: mocks.getLoadedRuntimePluginRegistry,
}));
vi.mock("./runtime.js", () => ({
getActivePluginRegistry: mocks.getActivePluginRegistry,
getActivePluginRegistryWorkspaceDir: mocks.getActivePluginRegistryWorkspaceDir,
}));
vi.mock("./runtime/load-context.js", () => ({
buildPluginRuntimeLoadOptionsFromValues: mocks.buildPluginRuntimeLoadOptionsFromValues,
createPluginRuntimeLoaderLogger: mocks.createPluginRuntimeLoaderLogger,
}));
let resolvePluginWebProviders: typeof import("./web-provider-runtime-shared.js").resolvePluginWebProviders;
let resolveRuntimeWebProviders: typeof import("./web-provider-runtime-shared.js").resolveRuntimeWebProviders;
describe("web-provider-runtime-shared", () => {
beforeAll(async () => {
({ resolvePluginWebProviders, resolveRuntimeWebProviders } =
await import("./web-provider-runtime-shared.js"));
});
beforeEach(() => {
mocks.isPluginRegistryLoadInFlight.mockReset();
mocks.isPluginRegistryLoadInFlight.mockReturnValue(false);
mocks.loadOpenClawPlugins.mockReset();
mocks.resolveCompatibleRuntimePluginRegistry.mockReset();
mocks.getLoadedRuntimePluginRegistry.mockReset();
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(undefined);
mocks.resolvePluginRegistryLoadCacheKey.mockReset();
mocks.resolvePluginRegistryLoadCacheKey.mockImplementation((options: unknown) =>
JSON.stringify(options),
);
mocks.resolveRuntimePluginRegistry.mockReset();
mocks.getActivePluginRegistry.mockReset();
mocks.getActivePluginRegistry.mockReturnValue(null);
mocks.getActivePluginRegistryWorkspaceDir.mockReset();
mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue(undefined);
mocks.buildPluginRuntimeLoadOptionsFromValues.mockReset();
mocks.buildPluginRuntimeLoadOptionsFromValues.mockImplementation(
(_values: unknown, overrides?: Record<string, unknown>) => ({
...overrides,
}),
);
});
it("preserves explicit empty scopes in runtime-compatible web provider loads", () => {
const mapRegistryProviders = vi.fn(() => []);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue({} as never);
resolvePluginWebProviders(
{
config: {},
onlyPluginIds: [],
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => [],
mapRegistryProviders,
},
);
expect(mocks.getLoadedRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
requiredPluginIds: [],
}),
);
expect(mapRegistryProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
}),
);
});
it("preserves explicit empty scopes in direct runtime web provider resolution", () => {
const mapRegistryProviders = vi.fn(() => []);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue({} as never);
resolveRuntimeWebProviders(
{
config: {},
onlyPluginIds: [],
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => [],
mapRegistryProviders,
},
);
expect(mocks.getLoadedRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
requiredPluginIds: [],
}),
);
expect(mapRegistryProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
}),
);
});
it("preserves explicit scopes when config is omitted in direct runtime resolution", () => {
const mapRegistryProviders = vi.fn(() => []);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue({} as never);
resolveRuntimeWebProviders(
{
onlyPluginIds: ["alpha"],
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => ["alpha"],
mapRegistryProviders,
},
);
expect(mocks.getLoadedRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
requiredPluginIds: ["alpha"],
}),
);
expect(mapRegistryProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["alpha"],
}),
);
});
it("reuses the active registry after deriving web provider candidates from resolved config", () => {
const activeRegistry = { source: "active" };
const resolvedConfig = { plugins: { entries: { brave: { enabled: true } } } };
const resolveCandidatePluginIds = vi.fn(() => ["brave"]);
const mapRegistryProviders = vi.fn(() => ["provider"]);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry);
const providers = resolvePluginWebProviders(
{
config: { plugins: { entries: {} } },
env: { BRAVE_API_KEY: "key" },
onlyPluginIds: ["brave", "firecrawl"],
origin: "bundled",
workspaceDir: "/workspace",
},
{
resolveBundledResolutionConfig: () => ({
config: resolvedConfig,
activationSourceConfig: { plugins: { entries: {} } },
autoEnabledReasons: { brave: ["env"] },
}),
resolveCandidatePluginIds,
mapRegistryProviders,
},
);
expect(providers).toEqual(["provider"]);
expect(resolveCandidatePluginIds).toHaveBeenCalledWith({
config: resolvedConfig,
workspaceDir: "/workspace",
env: { BRAVE_API_KEY: "key" },
onlyPluginIds: ["brave", "firecrawl"],
origin: "bundled",
});
expect(mapRegistryProviders).toHaveBeenCalledWith({
registry: activeRegistry,
onlyPluginIds: ["brave"],
});
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("preserves explicit empty candidate scopes when reusing the active registry", () => {
const activeRegistry = { source: "active" };
const mapRegistryProviders = vi.fn(() => []);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry);
resolvePluginWebProviders(
{
config: {},
onlyPluginIds: [],
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => [],
mapRegistryProviders,
},
);
expect(mapRegistryProviders).toHaveBeenCalledWith({
registry: activeRegistry,
onlyPluginIds: [],
});
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("uses loaded runtime web providers without runtime plugin loads", () => {
const loadedRegistry = { source: "loaded" };
const mapRegistryProviders = vi.fn(() => ["provider"]);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(loadedRegistry as never);
const providers = resolvePluginWebProviders(
{
config: {},
onlyPluginIds: ["brave"],
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => ["brave"],
mapRegistryProviders,
},
);
expect(providers).toEqual(["provider"]);
expect(mocks.getLoadedRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
requiredPluginIds: ["brave"],
}),
);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("ignores runtime web provider cache opt-outs after startup loading", () => {
const loadedRegistry = { source: "loaded" };
const mapRegistryProviders = vi.fn(() => ["provider"]);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(loadedRegistry as never);
resolvePluginWebProviders(
{
cache: false,
config: {},
onlyPluginIds: ["brave"],
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => ["brave"],
mapRegistryProviders,
},
);
expect(mocks.getLoadedRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
requiredPluginIds: ["brave"],
}),
);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("caches setup web provider plugin loads by default", () => {
const loadedRegistry = { source: "setup" };
const mapRegistryProviders = vi.fn(() => ["provider"]);
mocks.loadOpenClawPlugins.mockReturnValue(loadedRegistry as never);
const providers = resolvePluginWebProviders(
{
config: {},
mode: "setup",
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => ["brave"],
mapRegistryProviders,
resolveBundledPublicArtifactProviders: () => null,
},
);
expect(providers).toEqual(["provider"]);
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
cache: true,
onlyPluginIds: ["brave"],
}),
);
});
it("falls back to a scoped provider load when the active runtime registry has no web providers", () => {
const activeRegistry = { source: "active" };
const fallbackRegistry = { source: "fallback" };
const mapRegistryProviders = vi.fn(({ registry }) =>
registry === fallbackRegistry ? ["brave"] : [],
);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry as never);
mocks.loadOpenClawPlugins.mockReturnValue(fallbackRegistry as never);
const result = resolvePluginWebProviders(
{
config: {},
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => undefined,
mapRegistryProviders,
},
);
expect(result).toEqual(["brave"]);
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
expect(mapRegistryProviders).toHaveBeenCalledTimes(2);
});
it("does not fall back when the active runtime registry returns empty under an explicit empty scope", () => {
const activeRegistry = { source: "active" };
const mapRegistryProviders = vi.fn(() => []);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry as never);
const result = resolvePluginWebProviders(
{
config: {},
onlyPluginIds: [],
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => [],
mapRegistryProviders,
},
);
expect(result).toEqual([]);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("falls back when the direct runtime registry has no web providers", () => {
const activeRegistry = { source: "active" };
const fallbackRegistry = { source: "fallback" };
const mapRegistryProviders = vi.fn(({ registry }) =>
registry === fallbackRegistry ? ["brave"] : [],
);
mocks.getLoadedRuntimePluginRegistry.mockImplementation((args: unknown) => {
const requiredPluginIds = (args as { requiredPluginIds?: readonly string[] })
?.requiredPluginIds;
if (requiredPluginIds === undefined) {
return activeRegistry as never;
}
return undefined;
});
mocks.loadOpenClawPlugins.mockReturnValue(fallbackRegistry as never);
const result = resolveRuntimeWebProviders(
{
config: {},
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => undefined,
mapRegistryProviders,
},
);
expect(result).toEqual(["brave"]);
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
});
it("does not fall back when direct runtime registry returns empty under an explicit empty scope", () => {
const activeRegistry = { source: "active" };
const mapRegistryProviders = vi.fn(() => []);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry as never);
const result = resolveRuntimeWebProviders(
{
config: {},
onlyPluginIds: [],
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => [],
mapRegistryProviders,
},
);
expect(result).toEqual([]);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("keeps explicit setup web provider cache opt-outs", () => {
const loadedRegistry = { source: "setup" };
const mapRegistryProviders = vi.fn(() => ["provider"]);
mocks.loadOpenClawPlugins.mockReturnValue(loadedRegistry as never);
resolvePluginWebProviders(
{
cache: false,
config: {},
mode: "setup",
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => ["brave"],
mapRegistryProviders,
resolveBundledPublicArtifactProviders: () => null,
},
);
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
cache: false,
onlyPluginIds: ["brave"],
}),
);
});
});