diff --git a/extensions/google/web-search-contract-api.ts b/extensions/google/web-search-contract-api.ts index dbb5cd099fd..c2fdfbf9eb8 100644 --- a/extensions/google/web-search-contract-api.ts +++ b/extensions/google/web-search-contract-api.ts @@ -1,28 +1 @@ -import { - createWebSearchProviderContractFields, - type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search-config-contract"; - -export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { - const credentialPath = "plugins.entries.google.config.webSearch.apiKey"; - - return { - id: "gemini", - label: "Gemini (Google Search)", - hint: "Requires Google Gemini API key ยท Google Search grounding", - onboardingScopes: ["text-inference"], - credentialLabel: "Google Gemini API key", - envVars: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://aistudio.google.com/apikey", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 20, - credentialPath, - ...createWebSearchProviderContractFields({ - credentialPath, - searchCredential: { type: "scoped", scopeId: "gemini" }, - configuredCredential: { pluginId: "google" }, - }), - createTool: () => null, - }; -} +export { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; diff --git a/src/plugins/contracts/registry.retry.test.ts b/src/plugins/contracts/registry.retry.test.ts index c61071306d8..2461a7151f6 100644 --- a/src/plugins/contracts/registry.retry.test.ts +++ b/src/plugins/contracts/registry.retry.test.ts @@ -228,6 +228,42 @@ describe("plugin contract registry scoped retries", () => { expect(loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled(); }); + it("uses web search public artifacts before falling back to the bundled runtime registry", async () => { + const loadBundledCapabilityRuntimeRegistry = vi.fn(() => { + throw new Error( + "web search contract vitest fast path should not hit bundled runtime registry", + ); + }); + const loadVitestWebSearchProviderContractRegistry = vi.fn(() => [ + { + pluginId: "google", + provider: { + id: "gemini", + label: "Gemini", + credentialPath: "plugins.entries.google.config.webSearch.apiKey", + } as WebSearchProviderPlugin, + credentialValue: "AIzaSyDUMMY", + }, + ]); + + vi.doMock("../bundled-capability-runtime.js", () => ({ + loadBundledCapabilityRuntimeRegistry, + })); + vi.doMock("./web-provider-vitest-registry.js", () => ({ + loadVitestWebSearchProviderContractRegistry, + })); + + const { resolveWebSearchProviderContractEntriesForPluginId } = await import("./registry.js"); + + expect( + resolveWebSearchProviderContractEntriesForPluginId("google").map( + (entry) => entry.provider.id, + ), + ).toEqual(["gemini"]); + expect(loadVitestWebSearchProviderContractRegistry).toHaveBeenCalledTimes(1); + expect(loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled(); + }); + it("retries web fetch provider loads after a transient plugin-scoped runtime error", async () => { const loadBundledCapabilityRuntimeRegistry = vi .fn() diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 369ba470770..3a82a78beb3 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -28,6 +28,7 @@ import { loadVitestSpeechProviderContractRegistry, loadVitestVideoGenerationProviderContractRegistry, } from "./speech-vitest-registry.js"; +import { loadVitestWebSearchProviderContractRegistry } from "./web-provider-vitest-registry.js"; type BundledCapabilityRuntimeRegistry = ReturnType; type CapabilityContractEntry = { @@ -474,15 +475,23 @@ export function resolveWebFetchProviderContractEntriesForPluginId( function loadWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] { if (!webSearchProviderContractRegistryCache) { - const registry = loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestContractPluginIds("webSearchProviders"), - pluginSdkResolution: "dist", - }); - webSearchProviderContractRegistryCache = registry.webSearchProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - credentialValue: resolveWebSearchCredentialValue(entry.provider), - })); + const vitestEntries = process.env.VITEST ? loadVitestWebSearchProviderContractRegistry() : []; + const coveredPluginIds = new Set(vitestEntries.map((entry) => entry.pluginId)); + const remainingPluginIds = resolveBundledManifestContractPluginIds("webSearchProviders").filter( + (pluginId) => !coveredPluginIds.has(pluginId), + ); + const runtimeEntries = + remainingPluginIds.length > 0 + ? loadBundledCapabilityRuntimeRegistry({ + pluginIds: remainingPluginIds, + pluginSdkResolution: "dist", + }).webSearchProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + credentialValue: resolveWebSearchCredentialValue(entry.provider), + })) + : []; + webSearchProviderContractRegistryCache = [...vitestEntries, ...runtimeEntries]; } return webSearchProviderContractRegistryCache; } @@ -503,6 +512,16 @@ export function resolveWebSearchProviderContractEntriesForPluginId( return cached; } + if (process.env.VITEST) { + const vitestEntries = loadVitestWebSearchProviderContractRegistry().filter( + (entry) => entry.pluginId === pluginId, + ); + if (vitestEntries.length > 0) { + cache.set(pluginId, vitestEntries); + return vitestEntries; + } + } + const entries = loadScopedCapabilityRuntimeRegistryEntries({ pluginId, capabilityLabel: "web search provider", diff --git a/src/plugins/contracts/web-provider-vitest-registry.ts b/src/plugins/contracts/web-provider-vitest-registry.ts new file mode 100644 index 00000000000..7be4398a678 --- /dev/null +++ b/src/plugins/contracts/web-provider-vitest-registry.ts @@ -0,0 +1,21 @@ +import { createGeminiWebSearchProvider } from "../../../extensions/google/web-search-contract-api.js"; +import type { WebSearchProviderPlugin } from "../types.js"; + +export type WebSearchProviderContractEntry = { + pluginId: string; + provider: WebSearchProviderPlugin; + credentialValue: unknown; +}; + +let webSearchProviderContractRegistryCache: WebSearchProviderContractEntry[] | null = null; + +export function loadVitestWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] { + webSearchProviderContractRegistryCache ??= [ + { + pluginId: "google", + provider: createGeminiWebSearchProvider(), + credentialValue: "AIzaSyDUMMY", + }, + ]; + return webSearchProviderContractRegistryCache; +} diff --git a/src/test-utils/bundled-plugin-public-surface.ts b/src/test-utils/bundled-plugin-public-surface.ts index a84bda882bd..17434394dcc 100644 --- a/src/test-utils/bundled-plugin-public-surface.ts +++ b/src/test-utils/bundled-plugin-public-surface.ts @@ -150,3 +150,18 @@ export function resolveRelativeBundledPluginPublicModuleId(params: { .replaceAll(path.sep, "/"); return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; } + +export function resolveRelativeExtensionPublicModuleId(params: { + fromModuleUrl: string; + dirName: string; + artifactBasename: string; +}): string { + const fromFilePath = fileURLToPath(params.fromModuleUrl); + const targetPath = resolveVitestSourceModulePath( + path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions", params.dirName, params.artifactBasename), + ); + const relativePath = path + .relative(path.dirname(fromFilePath), targetPath) + .replaceAll(path.sep, "/"); + return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; +} diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index 376c9b8e779..fca91bd5200 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -1,17 +1,23 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { loadBundledPluginPublicSurfaceModuleSync } from "../../../src/plugin-sdk/facade-loader.js"; import { __testing as pluginLoaderTesting } from "../../../src/plugins/loader.js"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { SpeechProviderPlugin } from "../../../src/plugins/types.js"; +import { resolveRelativeExtensionPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { withEnv } from "../../../src/test-utils/env.js"; import { summarizeText as summarizeTextCore } from "../../../src/tts/tts-core.js"; import type { ResolvedTtsConfig } from "../../../src/tts/tts-types.js"; type TtsRuntimeModule = typeof import("../../../src/tts/tts.js"); +const speechCoreRuntimeApiModuleId = resolveRelativeExtensionPublicModuleId({ + fromModuleUrl: import.meta.url, + dirName: "speech-core", + artifactBasename: "runtime-api.js", +}); + let ttsRuntime: TtsRuntimeModule; let ttsRuntimePromise: Promise | null = null; let ttsRuntimeInitialized = false; @@ -389,12 +395,7 @@ function buildTestGoogleSpeechProvider(): SpeechProviderPlugin { } async function loadTtsRuntime(): Promise { - ttsRuntimePromise ??= Promise.resolve( - loadBundledPluginPublicSurfaceModuleSync({ - dirName: "speech-core", - artifactBasename: "runtime-api.js", - }), - ); + ttsRuntimePromise ??= import(speechCoreRuntimeApiModuleId) as Promise; return await ttsRuntimePromise; }