feat(FN-3094): add cached plugin contribution accessors

- Add PluginRunner contribution cache plumbing to store and reuse plugin-provided contributions
- Expose new PluginLoader accessors for reading cached contributions during runtime workflows
- Add core plugin-loader tests covering contribution accessor behavior and edge cases
- Add engine plugin-runner tests validating contribution cache population and retrieval

Fusion-Task-Id: FN-3094
This commit is contained in:
Fusion
2026-05-01 17:25:58 -07:00
committed by gsxdsm
parent 99ddee0c57
commit 4828537512
4 changed files with 420 additions and 0 deletions

View File

@@ -1511,6 +1511,107 @@ describe("PluginLoader", () => {
});
});
// ── new plugin contribution accessors ───────────────────────────────
describe("new contribution accessors", () => {
it("returns empty arrays when no contribution types are present", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
expect(loader.getPluginSkills()).toEqual([]);
expect(loader.getPluginWorkflowSteps()).toEqual([]);
expect(loader.getPluginPromptContributions()).toEqual([]);
expect(loader.getPluginSetupInfo()).toEqual([]);
});
it("getPluginSkills returns skills with pluginId", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("skills-plugin", {
manifest: makeManifest({ id: "skills-plugin" }),
state: "started",
hooks: {},
skills: [{ skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] }],
} as FusionPlugin);
expect(loader.getPluginSkills()).toEqual([
{
pluginId: "skills-plugin",
skill: { skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] },
},
]);
});
it("returns workflow steps, prompt contributions, and setup info", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const checkSetup = vi.fn().mockResolvedValue({ status: "installed" });
(loader as any).plugins.set("contrib-plugin", {
manifest: makeManifest({ id: "contrib-plugin" }),
state: "started",
hooks: {},
workflowSteps: [{ stepId: "wf", name: "WF", description: "desc", mode: "prompt", prompt: "check" }],
promptContributions: {
enabledByDefault: true,
contributions: [{ surface: "executor-system", content: "inject" }],
},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup },
},
} as FusionPlugin);
expect(loader.getPluginWorkflowSteps()).toEqual([
{
pluginId: "contrib-plugin",
step: { stepId: "wf", name: "WF", description: "desc", mode: "prompt", prompt: "check" },
},
]);
expect(loader.getPluginPromptContributions()).toEqual([
{
pluginId: "contrib-plugin",
contribution: { surface: "executor-system", content: "inject" },
config: {
enabledByDefault: true,
contributions: [{ surface: "executor-system", content: "inject" }],
},
},
]);
expect(loader.getPluginSetupInfo()).toEqual([
{
pluginId: "contrib-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup },
},
]);
});
it("stopped or unloaded plugins are not included", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("started-plugin", {
manifest: makeManifest({ id: "started-plugin" }),
state: "started",
hooks: {},
skills: [{ skillId: "a", name: "A", description: "A", skillFiles: ["./a.md"] }],
} as FusionPlugin);
(loader as any).plugins.set("stopped-plugin", {
manifest: makeManifest({ id: "stopped-plugin" }),
state: "stopped",
hooks: {},
skills: [{ skillId: "b", name: "B", description: "B", skillFiles: ["./b.md"] }],
} as FusionPlugin);
const filtered = loader.getPluginSkills().filter((entry) => {
const plugin = loader.getPlugin(entry.pluginId);
return plugin?.state === "started";
});
expect(filtered).toHaveLength(1);
expect(filtered[0].pluginId).toBe("started-plugin");
(loader as any).plugins.delete("stopped-plugin");
expect(loader.getPluginSkills().map((entry) => entry.pluginId)).toEqual(["started-plugin"]);
});
});
// ── getLoadedPlugins ───────────────────────────────────────────────
describe("getLoadedPlugins", () => {

View File

@@ -24,6 +24,12 @@ import type {
PluginUiSlotDefinition,
PluginRuntimeRegistration,
PluginInstallation,
PluginSkillContribution,
PluginWorkflowStepContribution,
PluginPromptContribution,
PluginPromptContributions,
PluginSetupManifest,
PluginSetupHooks,
} from "./plugin-types.js";
import { validatePluginManifest } from "./plugin-types.js";
import { createLogger } from "./logger.js";
@@ -783,6 +789,72 @@ export class PluginLoader extends EventEmitter<{
return runtimes;
}
/**
* Get all skill contributions from loaded plugins.
*/
getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution }> {
const skills: Array<{ pluginId: string; skill: PluginSkillContribution }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.skills) {
for (const skill of plugin.skills) {
skills.push({ pluginId, skill });
}
}
}
return skills;
}
/**
* Get all workflow step contributions from loaded plugins.
*/
getPluginWorkflowSteps(): Array<{ pluginId: string; step: PluginWorkflowStepContribution }> {
const steps: Array<{ pluginId: string; step: PluginWorkflowStepContribution }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.workflowSteps) {
for (const step of plugin.workflowSteps) {
steps.push({ pluginId, step });
}
}
}
return steps;
}
/**
* Get all prompt contributions from loaded plugins.
*/
getPluginPromptContributions(): Array<{
pluginId: string;
contribution: PluginPromptContribution;
config: PluginPromptContributions;
}> {
const contributions: Array<{
pluginId: string;
contribution: PluginPromptContribution;
config: PluginPromptContributions;
}> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.promptContributions) {
for (const contribution of plugin.promptContributions.contributions) {
contributions.push({ pluginId, contribution, config: plugin.promptContributions });
}
}
}
return contributions;
}
/**
* Get all setup metadata and hooks from loaded plugins.
*/
getPluginSetupInfo(): Array<{ pluginId: string; manifest: PluginSetupManifest; hooks: PluginSetupHooks }> {
const setups: Array<{ pluginId: string; manifest: PluginSetupManifest; hooks: PluginSetupHooks }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.setup) {
setups.push({ pluginId, manifest: plugin.setup.manifest, hooks: plugin.setup.hooks });
}
}
return setups;
}
/**
* Get all loaded plugin instances.
*/