mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 10:30:44 +00:00
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.
480 lines
15 KiB
TypeScript
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"],
|
|
}),
|
|
);
|
|
});
|
|
});
|