fix: harden provider plugin install and hook compatibility

This commit is contained in:
Tak Hoffman 2026-03-12 16:45:45 -05:00
parent 685f6c5132
commit e2b7c4c6a3
8 changed files with 490 additions and 93 deletions

View File

@ -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: [

View File

@ -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,

View File

@ -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 = {};

View File

@ -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;

View File

@ -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) {

View File

@ -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,
});

View File

@ -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,
};
}

View File

@ -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"