feat: treat bundled Tavily as a bundled provider

This commit is contained in:
Tak Hoffman 2026-03-12 17:28:08 -05:00
parent e2b7c4c6a3
commit 04769d7fe2
3 changed files with 146 additions and 23 deletions

View File

@ -23,6 +23,9 @@ const mocks = vi.hoisted(() => ({
const loadOpenClawPlugins = vi.hoisted(() =>
vi.fn(() => ({ searchProviders: [] as unknown[], plugins: [] as unknown[] })),
);
const loadPluginManifestRegistry = vi.hoisted(() =>
vi.fn(() => ({ plugins: [] as unknown[], diagnostics: [] as unknown[] })),
);
const ensureOnboardingPluginInstalled = vi.hoisted(() =>
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
);
@ -55,6 +58,10 @@ vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins,
}));
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry,
}));
vi.mock("./onboarding/plugin-install.js", () => ({
ensureOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
@ -144,6 +151,8 @@ describe("runConfigureWizard", () => {
mocks.summarizeExistingConfig.mockReset();
loadOpenClawPlugins.mockReset();
loadOpenClawPlugins.mockReturnValue({ searchProviders: [], plugins: [] });
loadPluginManifestRegistry.mockReset();
loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
ensureOnboardingPluginInstalled.mockReset();
ensureOnboardingPluginInstalled.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
@ -241,7 +250,6 @@ describe("runConfigureWizard", () => {
tools: expect.objectContaining({
web: expect.objectContaining({
search: expect.objectContaining({
provider: "tavily",
enabled: true,
}),
}),
@ -327,6 +335,7 @@ describe("runConfigureWizard", () => {
}),
}),
);
expect(mocks.writeConfigFile.mock.calls[0]?.[0]?.tools?.web?.search?.provider).toBeUndefined();
});
it("re-prompts invalid plugin config values during configure", async () => {
@ -436,7 +445,7 @@ describe("runConfigureWizard", () => {
);
});
it("installs a plugin search provider from configure and continues setup", async () => {
it("configures a bundled plugin search provider from configure without the external install step", async () => {
loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => {
const enabled = config.plugins?.entries?.["tavily-search"]?.enabled === true;
return enabled
@ -482,6 +491,36 @@ describe("runConfigureWizard", () => {
}
: { searchProviders: [], plugins: [] };
});
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "Search the web using Tavily.",
origin: "bundled",
source: "/tmp/bundled/tavily-search",
configSchema: {
type: "object",
required: ["apiKey"],
properties: {
apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" },
searchDepth: { type: "string", enum: ["basic", "advanced"] },
},
},
configUiHints: {
apiKey: {
label: "Tavily API key",
placeholder: "tvly-...",
sensitive: true,
},
searchDepth: {
label: "Search depth",
},
},
},
],
diagnostics: [],
});
ensureOnboardingPluginInstalled.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
@ -523,7 +562,7 @@ describe("runConfigureWizard", () => {
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
if (params.message === "Choose active web search provider") {
return "__install_plugin__";
return "tavily";
}
if (params.message.startsWith("Search depth")) {
return "advanced";
@ -541,23 +580,8 @@ describe("runConfigureWizard", () => {
},
);
expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({
id: "tavily-search",
install: expect.objectContaining({
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
}),
}),
workspaceDir: "/tmp/configure-install-workspace",
}),
);
expect(reloadOnboardingPluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/configure-install-workspace",
}),
);
expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled();
expect(reloadOnboardingPluginRegistry).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({

View File

@ -182,6 +182,66 @@ describe("setupSearch", () => {
);
});
it("shows bundled plugin providers directly in the picker instead of the external install path", async () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [],
plugins: [],
typedHooks: [],
});
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "Search the web using Tavily.",
origin: "bundled",
source: "/tmp/bundled/tavily-search",
configSchema: {
type: "object",
required: ["apiKey"],
properties: {
apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" },
},
},
configUiHints: {
apiKey: {
label: "Tavily API key",
placeholder: "tvly-...",
sensitive: true,
},
},
},
],
diagnostics: [],
});
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({ selectValue: "__skip__" });
await setupSearch(cfg, runtime, prompter);
const providerSelectCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0]?.message === "Choose active web search provider",
);
expect(providerSelectCall?.[0]).toEqual(
expect.objectContaining({
options: expect.arrayContaining([
expect.objectContaining({
value: "tavily",
label: "Tavily Search",
hint: expect.stringContaining("Bundled plugin"),
}),
]),
}),
);
expect(providerSelectCall?.[0]?.options).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
value: "__install_plugin__",
}),
]),
);
});
it("uses the updated configure-or-install action label", async () => {
vi.stubEnv("BRAVE_API_KEY", "BSA-test-key");
loadOpenClawPlugins.mockReturnValue({

View File

@ -133,7 +133,13 @@ export function resolveInstallableSearchProviderPlugins(
const loadedPluginProviderIds = new Set(
providerEntries.filter((entry) => entry.kind === "plugin").map((entry) => entry.value),
);
return SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.map((entry) => ({
return SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.filter((entry) => {
const providerEntry = providerEntries.find(
(providerEntry) =>
providerEntry.kind === "plugin" && providerEntry.value === entry.providerId,
);
return providerEntry?.kind !== "plugin" || providerEntry.origin !== "bundled";
}).map((entry) => ({
...entry,
description: loadedPluginProviderIds.has(entry.providerId)
? `${entry.description} Already installed.`
@ -570,6 +576,39 @@ export async function resolveSearchProviderPickerEntries(
pluginEntries = [];
}
try {
loadPluginManifestRegistry({
config,
workspaceDir,
cache: false,
});
const loadedPluginProviderIds = new Set(pluginEntries.map((entry) => entry.value));
const bundledManifestEntries = SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.map((installEntry) =>
buildPluginSearchProviderEntryFromManifest({
config,
installEntry,
workspaceDir,
}),
)
.filter(
(entry): entry is PluginSearchProviderEntry =>
Boolean(entry) && entry.origin === "bundled" && !loadedPluginProviderIds.has(entry.value),
)
.map((entry) => {
const pluginConfig = getPluginConfig(config, entry.pluginId);
const validation = validatePluginSearchProviderConfig(entry, pluginConfig);
return {
...entry,
configured: validation.ok,
};
});
pluginEntries = [...pluginEntries, ...bundledManifestEntries].toSorted((left, right) =>
left.label.localeCompare(right.label),
);
} catch {
// Ignore manifest lookup failures and fall back to loaded entries only.
}
return [...builtins, ...pluginEntries];
}
@ -602,13 +641,13 @@ function buildPluginSearchProviderEntryFromManifest(params: {
value: params.installEntry.providerId,
label: params.installEntry.meta.label,
hint: [
params.installEntry.description || pluginRecord.description || "Plugin-provided web search",
pluginRecord.description || "Plugin-provided web search",
formatPluginSourceHint(pluginRecord.origin),
].join(" · "),
configured: false,
pluginId: pluginRecord.id,
origin: pluginRecord.origin,
description: params.installEntry.description || pluginRecord.description,
description: pluginRecord.description,
docsUrl: undefined,
configFieldOrder: undefined,
configJsonSchema: pluginRecord.configSchema,