fix: harden provider plugin install and hook compatibility
This commit is contained in:
parent
685f6c5132
commit
e2b7c4c6a3
@ -620,7 +620,7 @@ describe("setupSearch", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Provider setup",
|
||||
message: "Generic provider guidance.\n\nRead the provider docs before entering your key.",
|
||||
message: "Generic provider guidance.",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@ -701,7 +701,6 @@ describe("setupSearch", () => {
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
expect(result.tools?.web?.search?.provider).toBe("tavily");
|
||||
expect(afterActivate).toHaveBeenCalledTimes(1);
|
||||
expect(afterProviderActivate).toHaveBeenCalledTimes(1);
|
||||
expect(afterProviderActivate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@ -715,6 +714,76 @@ describe("setupSearch", () => {
|
||||
workspaceDir: undefined,
|
||||
}),
|
||||
);
|
||||
expect(afterActivate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires legacy after_search_provider_activate hooks when no generic provider hook is registered", async () => {
|
||||
const afterActivate = vi.fn();
|
||||
loadOpenClawPlugins.mockReturnValue({
|
||||
searchProviders: [
|
||||
{
|
||||
pluginId: "tavily-search",
|
||||
provider: {
|
||||
id: "tavily",
|
||||
name: "Tavily Search",
|
||||
description: "Plugin search",
|
||||
isAvailable: () => true,
|
||||
search: async () => ({ content: "ok" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "External Tavily plugin",
|
||||
origin: "workspace",
|
||||
source: "/tmp/tavily-search",
|
||||
configJsonSchema: undefined,
|
||||
configUiHints: undefined,
|
||||
},
|
||||
],
|
||||
typedHooks: [
|
||||
{
|
||||
pluginId: "tavily-search",
|
||||
hookName: "after_search_provider_activate",
|
||||
priority: 0,
|
||||
source: "/tmp/tavily-search",
|
||||
handler: afterActivate,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
enabled: true,
|
||||
apiKey: "BSA-test-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"tavily-search": {
|
||||
enabled: true,
|
||||
config: {
|
||||
apiKey: "tvly-existing-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { prompter } = createPrompter({
|
||||
actionValue: "__switch_active__",
|
||||
selectValue: "tavily",
|
||||
});
|
||||
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
expect(result.tools?.web?.search?.provider).toBe("tavily");
|
||||
expect(afterActivate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: "tavily",
|
||||
@ -793,6 +862,91 @@ describe("setupSearch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not switch active provider when plugin config is still invalid and unpromptable", async () => {
|
||||
loadOpenClawPlugins.mockReturnValue({
|
||||
searchProviders: [
|
||||
{
|
||||
pluginId: "tavily-search",
|
||||
provider: {
|
||||
id: "tavily",
|
||||
name: "Tavily Search",
|
||||
description: "Plugin search",
|
||||
search: async () => ({ content: "ok" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "External Tavily plugin",
|
||||
origin: "workspace",
|
||||
source: "/tmp/tavily-search",
|
||||
configJsonSchema: {
|
||||
type: "object",
|
||||
required: ["apiKey", "region"],
|
||||
properties: {
|
||||
apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" },
|
||||
region: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string" },
|
||||
},
|
||||
required: ["code"],
|
||||
},
|
||||
},
|
||||
},
|
||||
configUiHints: {
|
||||
apiKey: {
|
||||
label: "Tavily API key",
|
||||
placeholder: "tvly-...",
|
||||
sensitive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
typedHooks: [],
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
enabled: true,
|
||||
apiKey: "BSA-test-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"tavily-search": {
|
||||
enabled: true,
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { prompter, notes } = createPrompter({
|
||||
actionValue: "__switch_active__",
|
||||
selectValue: "tavily",
|
||||
textValue: "tvly-valid-key",
|
||||
});
|
||||
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.plugins?.entries?.["tavily-search"]?.enabled).toBe(true);
|
||||
expect(notes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Invalid plugin config",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the existing sensitive plugin config value when left blank", async () => {
|
||||
loadOpenClawPlugins.mockReturnValue({
|
||||
searchProviders: [
|
||||
|
||||
@ -333,46 +333,27 @@ async function maybeNoteBeforeSearchProviderConfigure(params: {
|
||||
prompter: WizardPrompter;
|
||||
workspaceDir?: string;
|
||||
}): Promise<void> {
|
||||
if (
|
||||
!params.hookRunner?.hasHooks("before_provider_configure") &&
|
||||
!params.hookRunner?.hasHooks("before_search_provider_configure")
|
||||
) {
|
||||
if (!params.hookRunner?.hasProviderConfigureHooks("search")) {
|
||||
return;
|
||||
}
|
||||
const activeProviderId =
|
||||
resolveCapabilitySlotSelection(params.config, "providers.search") ?? null;
|
||||
const ctx = { workspaceDir: params.workspaceDir };
|
||||
const genericResult = params.hookRunner.hasHooks("before_provider_configure")
|
||||
? await params.hookRunner.runBeforeProviderConfigure(
|
||||
{
|
||||
providerKind: "search",
|
||||
slot: "providers.search",
|
||||
providerId: params.provider.providerId,
|
||||
providerLabel: params.provider.providerLabel,
|
||||
providerSource: params.provider.providerSource,
|
||||
pluginId: params.provider.pluginId,
|
||||
intent: params.intent,
|
||||
activeProviderId,
|
||||
configured: params.provider.configured,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
: undefined;
|
||||
const searchResult = params.hookRunner.hasHooks("before_search_provider_configure")
|
||||
? await params.hookRunner.runBeforeSearchProviderConfigure(
|
||||
{
|
||||
providerId: params.provider.providerId,
|
||||
providerLabel: params.provider.providerLabel,
|
||||
providerSource: params.provider.providerSource,
|
||||
pluginId: params.provider.pluginId,
|
||||
intent: params.intent,
|
||||
activeProviderId,
|
||||
configured: params.provider.configured,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
: undefined;
|
||||
const note = [genericResult?.note, searchResult?.note].filter(hasNonEmptyString).join("\n\n");
|
||||
const result = await params.hookRunner.runBeforeProviderConfigure(
|
||||
{
|
||||
providerKind: "search",
|
||||
slot: "providers.search",
|
||||
providerId: params.provider.providerId,
|
||||
providerLabel: params.provider.providerLabel,
|
||||
providerSource: params.provider.providerSource,
|
||||
pluginId: params.provider.pluginId,
|
||||
intent: params.intent,
|
||||
activeProviderId,
|
||||
configured: params.provider.configured,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
const note = result?.note;
|
||||
if (note.trim()) {
|
||||
await params.prompter.note(note, "Provider setup");
|
||||
}
|
||||
@ -406,28 +387,15 @@ async function runAfterSearchProviderHooks(params: {
|
||||
activeProviderId: activeProviderAfter,
|
||||
configured: params.provider.configured,
|
||||
};
|
||||
const searchConfigureEvent = {
|
||||
providerId: params.provider.providerId,
|
||||
providerLabel: params.provider.providerLabel,
|
||||
providerSource: params.provider.providerSource,
|
||||
pluginId: params.provider.pluginId,
|
||||
intent: params.intent,
|
||||
activeProviderId: activeProviderAfter,
|
||||
configured: params.provider.configured,
|
||||
};
|
||||
|
||||
if (params.hookRunner.hasHooks("after_provider_configure")) {
|
||||
if (params.hookRunner.hasProviderConfigureHooks("search")) {
|
||||
await params.hookRunner.runAfterProviderConfigure(genericConfigureEvent, ctx);
|
||||
}
|
||||
if (params.hookRunner.hasHooks("after_search_provider_configure")) {
|
||||
await params.hookRunner.runAfterSearchProviderConfigure(searchConfigureEvent, ctx);
|
||||
}
|
||||
|
||||
if (
|
||||
activeProviderAfter === params.provider.providerId &&
|
||||
activeProviderBefore !== activeProviderAfter &&
|
||||
(params.hookRunner.hasHooks("after_provider_activate") ||
|
||||
params.hookRunner.hasHooks("after_search_provider_activate"))
|
||||
params.hookRunner.hasProviderActivationHooks("search")
|
||||
) {
|
||||
const genericActivateEvent = {
|
||||
providerKind: "search" as const,
|
||||
@ -439,20 +407,7 @@ async function runAfterSearchProviderHooks(params: {
|
||||
previousProviderId: activeProviderBefore,
|
||||
intent: params.intent,
|
||||
};
|
||||
const searchActivateEvent = {
|
||||
providerId: params.provider.providerId,
|
||||
providerLabel: params.provider.providerLabel,
|
||||
providerSource: params.provider.providerSource,
|
||||
pluginId: params.provider.pluginId,
|
||||
previousProviderId: activeProviderBefore,
|
||||
intent: params.intent,
|
||||
};
|
||||
if (params.hookRunner.hasHooks("after_provider_activate")) {
|
||||
await params.hookRunner.runAfterProviderActivate(genericActivateEvent, ctx);
|
||||
}
|
||||
if (params.hookRunner.hasHooks("after_search_provider_activate")) {
|
||||
await params.hookRunner.runAfterSearchProviderActivate(searchActivateEvent, ctx);
|
||||
}
|
||||
await params.hookRunner.runAfterProviderActivate(genericActivateEvent, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@ -460,12 +415,25 @@ async function promptPluginSearchProviderConfig(
|
||||
config: OpenClawConfig,
|
||||
entry: PluginSearchProviderEntry,
|
||||
prompter: WizardPrompter,
|
||||
): Promise<OpenClawConfig> {
|
||||
): Promise<{ config: OpenClawConfig; valid: boolean }> {
|
||||
let nextConfig = config;
|
||||
let nextPluginConfig = getPluginConfig(nextConfig, entry.pluginId);
|
||||
const fields = resolvePromptablePluginFields(entry, nextPluginConfig);
|
||||
if (fields.length === 0) {
|
||||
return config;
|
||||
const validation = validatePluginSearchProviderConfig(entry, nextPluginConfig);
|
||||
if (!validation.ok) {
|
||||
await prompter.note(
|
||||
validation.fieldKey
|
||||
? `${humanizeConfigKey(validation.fieldKey)}: ${validation.message}`
|
||||
: [
|
||||
"This provider needs configuration that this prompt cannot collect yet.",
|
||||
validation.message,
|
||||
].join("\n"),
|
||||
"Invalid plugin config",
|
||||
);
|
||||
return { config, valid: false };
|
||||
}
|
||||
return { config, valid: true };
|
||||
}
|
||||
|
||||
let fieldIndex = 0;
|
||||
@ -536,7 +504,7 @@ async function promptPluginSearchProviderConfig(
|
||||
}
|
||||
|
||||
nextConfig = setPluginConfig(nextConfig, entry.pluginId, nextPluginConfig);
|
||||
return nextConfig;
|
||||
return { config: nextConfig, valid: true };
|
||||
}
|
||||
|
||||
export async function resolveSearchProviderPickerEntries(
|
||||
@ -801,13 +769,24 @@ export async function applySearchProviderChoice(params: {
|
||||
prompter: params.prompter,
|
||||
workspaceDir: params.opts?.workspaceDir,
|
||||
});
|
||||
next = await promptPluginSearchProviderConfig(next, installedProvider, params.prompter);
|
||||
const result = preserveSearchProviderIntent(
|
||||
installedConfig,
|
||||
const pluginConfigResult = await promptPluginSearchProviderConfig(
|
||||
next,
|
||||
intent,
|
||||
installedProvider.value,
|
||||
installedProvider,
|
||||
params.prompter,
|
||||
);
|
||||
const result = pluginConfigResult.valid
|
||||
? preserveSearchProviderIntent(
|
||||
installedConfig,
|
||||
pluginConfigResult.config,
|
||||
intent,
|
||||
installedProvider.value,
|
||||
)
|
||||
: preserveSearchProviderIntent(
|
||||
installedConfig,
|
||||
enabled.config,
|
||||
"configure-provider",
|
||||
installedProvider.value,
|
||||
);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
originalConfig: installedConfig,
|
||||
@ -1020,8 +999,19 @@ export async function configureSearchProviderSelection(
|
||||
prompter,
|
||||
workspaceDir: opts?.workspaceDir,
|
||||
});
|
||||
next = await promptPluginSearchProviderConfig(next, selectedEntry, prompter);
|
||||
const result = preserveSearchProviderIntent(config, next, intent, selectedEntry.value);
|
||||
const pluginConfigResult = await promptPluginSearchProviderConfig(
|
||||
next,
|
||||
selectedEntry,
|
||||
prompter,
|
||||
);
|
||||
const result = pluginConfigResult.valid
|
||||
? preserveSearchProviderIntent(config, pluginConfigResult.config, intent, selectedEntry.value)
|
||||
: preserveSearchProviderIntent(
|
||||
config,
|
||||
enabled.config,
|
||||
"configure-provider",
|
||||
selectedEntry.value,
|
||||
);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
originalConfig: config,
|
||||
|
||||
@ -213,7 +213,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
it("uses a generic placeholder without prefilled local value on dev channel", async () => {
|
||||
expect(await runPromptShapeForChannel("dev")).toEqual(
|
||||
expect.objectContaining({
|
||||
message: "Plugin package or local path",
|
||||
message: "npm package or local path",
|
||||
placeholder: "@scope/plugin-name or extensions/plugin-name (leave blank to skip)",
|
||||
}),
|
||||
);
|
||||
@ -222,7 +222,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
it("uses the same generic placeholder without prefilled npm value on beta channel", async () => {
|
||||
expect(await runPromptShapeForChannel("beta")).toEqual(
|
||||
expect.objectContaining({
|
||||
message: "Plugin package or local path",
|
||||
message: "npm package or local path",
|
||||
placeholder: "@scope/plugin-name or extensions/plugin-name (leave blank to skip)",
|
||||
}),
|
||||
);
|
||||
@ -261,7 +261,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
|
||||
expect(text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Plugin package or local path",
|
||||
message: "npm package or local path",
|
||||
placeholder: "@scope/plugin-name or extensions/plugin-name (leave blank to skip)",
|
||||
}),
|
||||
);
|
||||
@ -311,7 +311,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
note,
|
||||
});
|
||||
const cfg: OpenClawConfig = {};
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
mockRepoLocalPathExists();
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "zalo",
|
||||
@ -346,7 +346,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
note,
|
||||
});
|
||||
const cfg: OpenClawConfig = {};
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
mockRepoLocalPathExists();
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "zalo",
|
||||
@ -363,7 +363,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"This flow installs @openclaw/zalo. Enter that package or a local plugin path.",
|
||||
"This flow installs @openclaw/zalo. Enter that npm package or a local plugin path.",
|
||||
"Plugin install",
|
||||
);
|
||||
expect(text).toHaveBeenCalledTimes(2);
|
||||
@ -393,6 +393,51 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses local path affordance when local paths are unavailable", async () => {
|
||||
const runtime = makeRuntime();
|
||||
const note = vi.fn(async () => {});
|
||||
const text = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("extensions/zalo")
|
||||
.mockResolvedValueOnce("@openclaw/zalo");
|
||||
const prompter = makePrompter({
|
||||
text: text as unknown as WizardPrompter["text"],
|
||||
note,
|
||||
});
|
||||
const cfg: OpenClawConfig = {};
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "zalo",
|
||||
targetDir: "/tmp/zalo",
|
||||
extensions: [],
|
||||
});
|
||||
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg,
|
||||
entry: baseEntry,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir: "/tmp/no-git-workspace",
|
||||
});
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"Local plugin paths are unavailable here. Enter an npm package.",
|
||||
"Plugin install",
|
||||
);
|
||||
expect(text).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
message: "npm package",
|
||||
placeholder: "@scope/plugin-name (leave blank to skip)",
|
||||
}),
|
||||
);
|
||||
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ spec: "@openclaw/zalo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears discovery cache before reloading the onboarding plugin registry", () => {
|
||||
const runtime = makeRuntime();
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
@ -120,13 +120,16 @@ async function promptInstallChoice(params: {
|
||||
allowLocal: boolean;
|
||||
}): Promise<string | null> {
|
||||
const { entry, prompter, workspaceDir, allowLocal } = params;
|
||||
const genericPlaceholder = "@scope/plugin-name or extensions/plugin-name (leave blank to skip)";
|
||||
const message = allowLocal ? "npm package or local path" : "npm package";
|
||||
const placeholder = allowLocal
|
||||
? "@scope/plugin-name or extensions/plugin-name (leave blank to skip)"
|
||||
: "@scope/plugin-name (leave blank to skip)";
|
||||
|
||||
while (true) {
|
||||
const source = (
|
||||
await prompter.text({
|
||||
message: "Plugin package or local path",
|
||||
placeholder: genericPlaceholder,
|
||||
message,
|
||||
placeholder,
|
||||
})
|
||||
).trim();
|
||||
|
||||
@ -141,13 +144,20 @@ async function promptInstallChoice(params: {
|
||||
|
||||
const looksLikePath = isLikelyLocalPath(source);
|
||||
if (looksLikePath) {
|
||||
await prompter.note(`Path not found: ${source}`, "Plugin install");
|
||||
await prompter.note(
|
||||
allowLocal
|
||||
? `Path not found: ${source}`
|
||||
: "Local plugin paths are unavailable here. Enter an npm package.",
|
||||
"Plugin install",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!matchesCatalogNpmSpec(source, entry.install.npmSpec)) {
|
||||
await prompter.note(
|
||||
`This flow installs ${entry.install.npmSpec}. Enter that package or a local plugin path.`,
|
||||
allowLocal
|
||||
? `This flow installs ${entry.install.npmSpec}. Enter that npm package or a local plugin path.`
|
||||
: `This flow installs ${entry.install.npmSpec}. Enter that npm package.`,
|
||||
"Plugin install",
|
||||
);
|
||||
continue;
|
||||
|
||||
@ -46,7 +46,7 @@ function resolvePluginDiagnosticPath(diag: {
|
||||
code?: string;
|
||||
slot?: string;
|
||||
}): string {
|
||||
if (diag.message.includes("plugin path not found")) {
|
||||
if (diag.code === "plugin_path_not_found") {
|
||||
return "plugins.load.paths";
|
||||
}
|
||||
if (diag.slot) {
|
||||
|
||||
@ -540,6 +540,7 @@ function discoverFromPath(params: {
|
||||
if (!fs.existsSync(resolved)) {
|
||||
params.diagnostics.push({
|
||||
level: "error",
|
||||
code: "plugin_path_not_found",
|
||||
message: `plugin path not found: ${resolved}`,
|
||||
source: resolved,
|
||||
});
|
||||
|
||||
@ -138,6 +138,95 @@ function getHooksForName<K extends PluginHookName>(
|
||||
.toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
}
|
||||
|
||||
function getHooksForNameWithoutPluginIds<K extends PluginHookName>(params: {
|
||||
registry: PluginRegistry;
|
||||
hookName: K;
|
||||
excludedPluginIds: ReadonlySet<string>;
|
||||
}): PluginHookRegistration<K>[] {
|
||||
return getHooksForName(params.registry, params.hookName).filter(
|
||||
(hook) => !params.excludedPluginIds.has(hook.pluginId),
|
||||
);
|
||||
}
|
||||
|
||||
type SearchProviderAliasDescriptor<
|
||||
TGenericName extends
|
||||
| "before_provider_configure"
|
||||
| "after_provider_configure"
|
||||
| "after_provider_activate",
|
||||
TLegacyName extends
|
||||
| "before_search_provider_configure"
|
||||
| "after_search_provider_configure"
|
||||
| "after_search_provider_activate",
|
||||
TGenericEvent,
|
||||
TLegacyEvent,
|
||||
> = {
|
||||
genericHookName: TGenericName;
|
||||
legacyHookName: TLegacyName;
|
||||
toLegacyEvent: (event: TGenericEvent) => TLegacyEvent;
|
||||
};
|
||||
|
||||
const SEARCH_PROVIDER_ALIAS_HOOKS = {
|
||||
beforeConfigure: {
|
||||
genericHookName: "before_provider_configure",
|
||||
legacyHookName: "before_search_provider_configure",
|
||||
toLegacyEvent: (
|
||||
event: PluginHookBeforeProviderConfigureEvent,
|
||||
): PluginHookBeforeSearchProviderConfigureEvent => ({
|
||||
providerId: event.providerId,
|
||||
providerLabel: event.providerLabel,
|
||||
providerSource: event.providerSource,
|
||||
pluginId: event.pluginId,
|
||||
intent: event.intent,
|
||||
activeProviderId: event.activeProviderId,
|
||||
configured: event.configured,
|
||||
}),
|
||||
} satisfies SearchProviderAliasDescriptor<
|
||||
"before_provider_configure",
|
||||
"before_search_provider_configure",
|
||||
PluginHookBeforeProviderConfigureEvent,
|
||||
PluginHookBeforeSearchProviderConfigureEvent
|
||||
>,
|
||||
afterConfigure: {
|
||||
genericHookName: "after_provider_configure",
|
||||
legacyHookName: "after_search_provider_configure",
|
||||
toLegacyEvent: (
|
||||
event: PluginHookAfterProviderConfigureEvent,
|
||||
): PluginHookAfterSearchProviderConfigureEvent => ({
|
||||
providerId: event.providerId,
|
||||
providerLabel: event.providerLabel,
|
||||
providerSource: event.providerSource,
|
||||
pluginId: event.pluginId,
|
||||
intent: event.intent,
|
||||
activeProviderId: event.activeProviderId,
|
||||
configured: event.configured,
|
||||
}),
|
||||
} satisfies SearchProviderAliasDescriptor<
|
||||
"after_provider_configure",
|
||||
"after_search_provider_configure",
|
||||
PluginHookAfterProviderConfigureEvent,
|
||||
PluginHookAfterSearchProviderConfigureEvent
|
||||
>,
|
||||
afterActivate: {
|
||||
genericHookName: "after_provider_activate",
|
||||
legacyHookName: "after_search_provider_activate",
|
||||
toLegacyEvent: (
|
||||
event: PluginHookAfterProviderActivateEvent,
|
||||
): PluginHookAfterSearchProviderActivateEvent => ({
|
||||
providerId: event.providerId,
|
||||
providerLabel: event.providerLabel,
|
||||
providerSource: event.providerSource,
|
||||
pluginId: event.pluginId,
|
||||
previousProviderId: event.previousProviderId,
|
||||
intent: event.intent,
|
||||
}),
|
||||
} satisfies SearchProviderAliasDescriptor<
|
||||
"after_provider_activate",
|
||||
"after_search_provider_activate",
|
||||
PluginHookAfterProviderActivateEvent,
|
||||
PluginHookAfterSearchProviderActivateEvent
|
||||
>,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Create a hook runner for a specific registry.
|
||||
*/
|
||||
@ -244,6 +333,15 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
||||
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
|
||||
): Promise<void> {
|
||||
const hooks = getHooksForName(registry, hookName);
|
||||
return runVoidHookRegistrations(hookName, hooks, event, ctx);
|
||||
}
|
||||
|
||||
async function runVoidHookRegistrations<K extends PluginHookName>(
|
||||
hookName: K,
|
||||
hooks: PluginHookRegistration<K>[],
|
||||
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
|
||||
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
|
||||
): Promise<void> {
|
||||
if (hooks.length === 0) {
|
||||
return;
|
||||
}
|
||||
@ -272,6 +370,16 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
||||
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult,
|
||||
): Promise<TResult | undefined> {
|
||||
const hooks = getHooksForName(registry, hookName);
|
||||
return runModifyingHookRegistrations(hookName, hooks, event, ctx, mergeResults);
|
||||
}
|
||||
|
||||
async function runModifyingHookRegistrations<K extends PluginHookName, TResult>(
|
||||
hookName: K,
|
||||
hooks: PluginHookRegistration<K>[],
|
||||
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
|
||||
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
|
||||
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult,
|
||||
): Promise<TResult | undefined> {
|
||||
if (hooks.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@ -301,6 +409,25 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
||||
return result;
|
||||
}
|
||||
|
||||
function getSearchProviderLegacyHooks<
|
||||
TGenericName extends
|
||||
| "before_provider_configure"
|
||||
| "after_provider_configure"
|
||||
| "after_provider_activate",
|
||||
TLegacyName extends
|
||||
| "before_search_provider_configure"
|
||||
| "after_search_provider_configure"
|
||||
| "after_search_provider_activate",
|
||||
>(descriptor: SearchProviderAliasDescriptor<TGenericName, TLegacyName, unknown, unknown>) {
|
||||
const genericHooks = getHooksForName(registry, descriptor.genericHookName);
|
||||
const genericPluginIds = new Set(genericHooks.map((hook) => hook.pluginId));
|
||||
return getHooksForNameWithoutPluginIds({
|
||||
registry,
|
||||
hookName: descriptor.legacyHookName,
|
||||
excludedPluginIds: genericPluginIds,
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Agent Hooks
|
||||
// =========================================================================
|
||||
@ -370,26 +497,70 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
||||
event: PluginHookBeforeProviderConfigureEvent,
|
||||
ctx: PluginHookSearchProviderContext,
|
||||
): Promise<PluginHookBeforeProviderConfigureResult | undefined> {
|
||||
return runModifyingHook<"before_provider_configure", PluginHookBeforeProviderConfigureResult>(
|
||||
const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.beforeConfigure;
|
||||
const genericResult = await runModifyingHook<
|
||||
"before_provider_configure",
|
||||
event,
|
||||
PluginHookBeforeProviderConfigureResult
|
||||
>("before_provider_configure", event, ctx, mergeBeforeProviderConfigure);
|
||||
if (event.providerKind !== "search") {
|
||||
return genericResult;
|
||||
}
|
||||
|
||||
const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor);
|
||||
const searchResult = await runModifyingHookRegistrations<
|
||||
"before_search_provider_configure",
|
||||
PluginHookBeforeSearchProviderConfigureResult
|
||||
>(
|
||||
aliasDescriptor.legacyHookName,
|
||||
legacyHooks,
|
||||
aliasDescriptor.toLegacyEvent(event),
|
||||
ctx,
|
||||
mergeBeforeProviderConfigure,
|
||||
mergeBeforeSearchProviderConfigure,
|
||||
);
|
||||
if (!searchResult) {
|
||||
return genericResult;
|
||||
}
|
||||
return mergeBeforeProviderConfigure(genericResult, {
|
||||
note: searchResult.note,
|
||||
});
|
||||
}
|
||||
|
||||
async function runAfterProviderConfigure(
|
||||
event: PluginHookAfterProviderConfigureEvent,
|
||||
ctx: PluginHookSearchProviderContext,
|
||||
): Promise<void> {
|
||||
return runVoidHook("after_provider_configure", event, ctx);
|
||||
const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.afterConfigure;
|
||||
await runVoidHook("after_provider_configure", event, ctx);
|
||||
if (event.providerKind !== "search") {
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor);
|
||||
await runVoidHookRegistrations(
|
||||
aliasDescriptor.legacyHookName,
|
||||
legacyHooks,
|
||||
aliasDescriptor.toLegacyEvent(event),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
async function runAfterProviderActivate(
|
||||
event: PluginHookAfterProviderActivateEvent,
|
||||
ctx: PluginHookSearchProviderContext,
|
||||
): Promise<void> {
|
||||
return runVoidHook("after_provider_activate", event, ctx);
|
||||
const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.afterActivate;
|
||||
await runVoidHook("after_provider_activate", event, ctx);
|
||||
if (event.providerKind !== "search") {
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor);
|
||||
await runVoidHookRegistrations(
|
||||
aliasDescriptor.legacyHookName,
|
||||
legacyHooks,
|
||||
aliasDescriptor.toLegacyEvent(event),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
async function runAfterSearchProviderConfigure(
|
||||
@ -810,6 +981,29 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
||||
return registry.typedHooks.filter((h) => h.hookName === hookName).length;
|
||||
}
|
||||
|
||||
function hasProviderConfigureHooks(providerKind?: string): boolean {
|
||||
if (hasHooks("before_provider_configure") || hasHooks("after_provider_configure")) {
|
||||
return true;
|
||||
}
|
||||
if (providerKind !== "search") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.beforeConfigure.legacyHookName) ||
|
||||
hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.afterConfigure.legacyHookName)
|
||||
);
|
||||
}
|
||||
|
||||
function hasProviderActivationHooks(providerKind?: string): boolean {
|
||||
if (hasHooks("after_provider_activate")) {
|
||||
return true;
|
||||
}
|
||||
if (providerKind !== "search") {
|
||||
return false;
|
||||
}
|
||||
return hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.afterActivate.legacyHookName);
|
||||
}
|
||||
|
||||
return {
|
||||
// Agent hooks
|
||||
runBeforeModelResolve,
|
||||
@ -849,6 +1043,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
||||
runGatewayStop,
|
||||
// Utility
|
||||
hasHooks,
|
||||
hasProviderConfigureHooks,
|
||||
hasProviderActivationHooks,
|
||||
getHookCount,
|
||||
};
|
||||
}
|
||||
|
||||
@ -486,6 +486,7 @@ export type PluginDiagnostic = {
|
||||
pluginId?: string;
|
||||
source?: string;
|
||||
code?:
|
||||
| "plugin_path_not_found"
|
||||
| "capability_declared_duplicate"
|
||||
| "capability_declared_not_registered"
|
||||
| "capability_missing_requirement"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user