feat(FN-3093): address plugin manager responsive overflow

The merge fixes responsive overflow issues in the PluginManager component by adding 15 lines of CSS.

Fusion-Task-Id: FN-3093
This commit is contained in:
Fusion
2026-05-01 16:46:45 -07:00
committed by gsxdsm
parent 92d7c7bbb8
commit 76797f71cb
8 changed files with 614 additions and 17 deletions

View File

@@ -1,4 +1,13 @@
import { describe, it, expect } from "vitest";
import type {
FusionPlugin,
PluginPromptContribution,
PluginPromptContributions,
PluginSetupHooks,
PluginSetupManifest,
PluginSkillContribution,
PluginWorkflowStepContribution,
} from "../plugin-types.js";
import { validatePluginManifest } from "../plugin-types.js";
describe("validatePluginManifest", () => {
@@ -991,3 +1000,187 @@ describe("PluginRuntimeRegistration", () => {
expect(typeof registration.factory).toBe("function");
});
});
describe("plugin contribution types", () => {
it("accepts a minimal PluginSkillContribution shape", () => {
const skill: PluginSkillContribution = {
skillId: "browser-scan",
name: "Browser Scan",
description: "Scans web pages",
skillFiles: ["skills/browser/SKILL.md"],
};
expect(skill.skillId).toBe("browser-scan");
});
it("accepts a full PluginSkillContribution shape", () => {
const skill: PluginSkillContribution = {
skillId: "deep-research",
name: "Deep Research",
description: "Performs deep research tasks",
skillFiles: ["skills/research/SKILL.md", "skills/research/README.md"],
enabled: false,
triggerPatterns: ["research", "investigate"],
};
expect(skill.enabled).toBe(false);
expect(skill.triggerPatterns).toContain("research");
});
it("accepts prompt and script workflow step contributions", () => {
const promptStep: PluginWorkflowStepContribution = {
stepId: "quality-review",
name: "Quality Review",
description: "Ask reviewer agent to evaluate quality",
mode: "prompt",
prompt: "Review this change",
toolMode: "readonly",
};
const scriptStep: PluginWorkflowStepContribution = {
stepId: "run-tests",
name: "Run Tests",
description: "Run test suite",
mode: "script",
scriptName: "test",
toolMode: "coding",
phase: "post-merge",
};
expect(promptStep.mode).toBe("prompt");
expect(scriptStep.mode).toBe("script");
});
it("accepts all plugin prompt contribution surfaces", () => {
const contributions: PluginPromptContribution[] = [
{ surface: "executor-system", content: "executor system" },
{ surface: "executor-task", content: "executor task", position: "prepend" },
{ surface: "triage", content: "triage" },
{ surface: "reviewer", content: "reviewer" },
{ surface: "heartbeat", content: "heartbeat", condition: "only for heartbeat audits" },
];
expect(contributions).toHaveLength(5);
expect(contributions[1]?.position).toBe("prepend");
expect(contributions[4]?.condition).toContain("heartbeat");
});
it("accepts prompt contributions wrapper with optional enabledByDefault", () => {
const promptContributions: PluginPromptContributions = {
contributions: [{ surface: "triage", content: "Always gather constraints" }],
};
expect(promptContributions.enabledByDefault).toBeUndefined();
});
it("accepts setup manifest and hooks shapes", async () => {
const manifest: PluginSetupManifest = {
binaryName: "agent-browser",
description: "Headless browser runtime",
channel: "stable",
defaultTimeoutMs: 120000,
};
const hooks: PluginSetupHooks = {
checkSetup: async () => ({ status: "installed", version: "1.2.3", binaryPath: "/tmp/agent-browser" }),
install: async () => {},
uninstall: async () => {},
};
const result = await hooks.checkSetup({} as any);
expect(manifest.binaryName).toBe("agent-browser");
expect(result.status).toBe("installed");
});
it("accepts FusionPlugin with all new contribution types and remains backward compatible", () => {
const withContributions: FusionPlugin = {
manifest: { id: "full-plugin", name: "Full Plugin", version: "1.0.0" },
state: "installed",
hooks: {},
skills: [{ skillId: "web-tools", name: "Web Tools", description: "Web helper", skillFiles: ["skills/SKILL.md"] }],
workflowSteps: [{ stepId: "verify", name: "Verify", description: "Verify output", mode: "prompt", prompt: "verify" }],
promptContributions: {
enabledByDefault: false,
contributions: [{ surface: "reviewer", content: "Use strict review" }],
},
setup: {
manifest: { binaryName: "agent-browser", description: "Browser runtime" },
hooks: {
checkSetup: async () => ({ status: "not-installed" }),
},
},
};
const backwardCompatible: FusionPlugin = {
manifest: { id: "legacy-plugin", name: "Legacy Plugin", version: "1.0.0" },
state: "installed",
hooks: {},
};
expect(withContributions.skills?.[0]?.skillId).toBe("web-tools");
expect(backwardCompatible.skills).toBeUndefined();
});
});
describe("validatePluginManifest contribution metadata", () => {
it("accepts valid contribution metadata", () => {
const result = validatePluginManifest({
id: "plugin-a",
name: "Plugin A",
version: "1.0.0",
skills: [{ skillId: "web-reader", name: "Web Reader" }],
workflowSteps: [{ stepId: "quality-gate", name: "Quality Gate", mode: "prompt" }],
promptSurfaces: ["executor-system", "reviewer"],
setup: { binaryName: "agent-browser", description: "Browser runtime", channel: "beta" },
});
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("rejects invalid skill slug metadata", () => {
const result = validatePluginManifest({
id: "plugin-a",
name: "Plugin A",
version: "1.0.0",
skills: [{ skillId: "Bad Skill", name: "Skill" }],
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("skills[0].skillId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
});
it("rejects invalid workflow step mode metadata", () => {
const result = validatePluginManifest({
id: "plugin-a",
name: "Plugin A",
version: "1.0.0",
workflowSteps: [{ stepId: "quality-gate", name: "Quality Gate", mode: "invalid" }],
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("workflowSteps[0].mode must be one of: prompt, script");
});
it("rejects invalid prompt surfaces metadata", () => {
const result = validatePluginManifest({
id: "plugin-a",
name: "Plugin A",
version: "1.0.0",
promptSurfaces: ["invalid-surface"],
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("promptSurfaces[0] must be one of: executor-system, executor-task, triage, reviewer, heartbeat");
});
it("rejects incomplete setup metadata", () => {
const result = validatePluginManifest({
id: "plugin-a",
name: "Plugin A",
version: "1.0.0",
setup: { binaryName: "" },
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("setup.binaryName is required and must be a non-empty string");
expect(result.errors).toContain("setup.description is required and must be a non-empty string");
});
});

View File

@@ -145,6 +145,15 @@ export type {
PluginRuntimeRegistration,
PluginContext,
PluginLogger,
PluginSkillContribution,
PluginWorkflowStepContribution,
PluginPromptSurface,
PluginPromptContribution,
PluginPromptContributions,
PluginSetupStatus,
PluginSetupCheckResult,
PluginSetupHooks,
PluginSetupManifest,
FusionPlugin,
PluginState,
PluginInstallation,

View File

@@ -12,6 +12,11 @@
*/
import type { TaskStore } from "./store.js";
import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
const PROMPT_CONTRIBUTION_SURFACES = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"] as const;
const SETUP_CHANNELS = ["stable", "beta", "nightly"] as const;
// ── Plugin Manifest ───────────────────────────────────────────────────
@@ -39,6 +44,14 @@ export interface PluginManifest {
settingsSchema?: Record<string, PluginSettingSchema>;
/** Optional agent runtime metadata for discovery (runtime factory is in FusionPlugin.runtime) */
runtime?: PluginRuntimeManifestMetadata;
/** Optional skill metadata used for discovery UIs. */
skills?: Array<{ skillId: string; name: string }>;
/** Optional workflow step metadata used for discovery UIs. */
workflowSteps?: Array<{ stepId: string; name: string }>;
/** Prompt surfaces this plugin contributes to. */
promptSurfaces?: PluginPromptSurface[];
/** Setup metadata for plugin-managed binaries/runtimes. */
setup?: PluginSetupManifest;
}
// ── Plugin Setting Schema ──────────────────────────────────────────────
@@ -206,6 +219,122 @@ export interface PluginRuntimeRegistration {
factory: PluginRuntimeFactory;
}
// ── Plugin Contribution Types ───────────────────────────────────────
/**
* Plugin-contributed skill surfaced in agent sessions via the skill-selection system.
*/
export interface PluginSkillContribution {
/** Unique skill identifier within the plugin namespace (kebab-case). */
skillId: string;
/** Human-readable skill name. */
name: string;
/** What the skill does. */
description: string;
/** Paths (relative to plugin root) to SKILL.md or equivalent definitions. */
skillFiles: string[];
/** Whether this skill is enabled by default. Defaults to true. */
enabled?: boolean;
/** Optional keyword/pattern hints used by skill matching. */
triggerPatterns?: string[];
}
/**
* Workflow step template contributed by a plugin. These templates are
* materialized into concrete WorkflowStep instances when selected for a task.
*/
export interface PluginWorkflowStepContribution {
/** Unique step identifier within the plugin namespace (kebab-case). */
stepId: string;
/** Human-readable step name. */
name: string;
/** Short description for UI. */
description: string;
/** Execution mode, aligned with WorkflowStepMode. */
mode: WorkflowStepMode;
/** Task lifecycle phase where this step runs. Defaults to "pre-merge". */
phase?: "pre-merge" | "post-merge";
/** Prompt text used when mode is "prompt". */
prompt?: string;
/** Script name used when mode is "script". */
scriptName?: string;
/** Tool access level, aligned with WorkflowStepToolMode. */
toolMode?: WorkflowStepToolMode;
/** Whether this step is enabled by default. Defaults to true. */
enabled?: boolean;
/** Whether this step is auto-selected on new tasks. */
defaultOn?: boolean;
/** Optional model provider override for prompt steps. */
modelProvider?: string;
/** Optional model ID override for prompt steps. */
modelId?: string;
}
/**
* Prompt injection surfaces for plugin-contributed instructions.
* - executor-system: Appended to executor agent system prompt
* - executor-task: Injected into per-task execution context
* - triage: Appended to triage/planning prompts
* - reviewer: Appended to reviewer/validation prompts
* - heartbeat: Appended to heartbeat agent system prompts
*/
export type PluginPromptSurface = (typeof PROMPT_CONTRIBUTION_SURFACES)[number];
export interface PluginPromptContribution {
/** Which prompt surface this contribution targets. */
surface: PluginPromptSurface;
/** Prompt text to inject. */
content: string;
/** Position relative to existing prompt content. Defaults to "append". */
position?: "append" | "prepend";
/** Human-readable applicability description, reserved for future filtering. */
condition?: string;
}
export interface PluginPromptContributions {
contributions: PluginPromptContribution[];
/** Whether contributions are active by default. Defaults to false for safety. */
enabledByDefault?: boolean;
}
export type PluginSetupStatus = "not-installed" | "installing" | "installed" | "error";
export interface PluginSetupCheckResult {
status: PluginSetupStatus;
/** Installed version if available. */
version?: string;
/** Installed binary path if detected. */
binaryPath?: string;
/** Error details when status is "error". */
error?: string;
}
/**
* Plugin-managed setup hooks. All process execution in hooks MUST be async
* (never execSync) to avoid blocking the engine event loop.
*/
export interface PluginSetupHooks {
/** Check whether required binaries/runtimes are installed and ready. */
checkSetup: (ctx: PluginContext) => Promise<PluginSetupCheckResult>;
/** Install required binaries/runtimes. */
install?: (ctx: PluginContext) => Promise<void>;
/** Uninstall managed binaries/runtimes. */
uninstall?: (ctx: PluginContext) => Promise<void>;
}
export interface PluginSetupManifest {
/** Binary/runtime name being managed (e.g. "agent-browser"). */
binaryName: string;
/** What this binary/runtime provides. */
description: string;
/** Expected or pinned version. */
version?: string;
/** Installation channel. */
channel?: (typeof SETUP_CHANNELS)[number];
/** Timeout for setup/install commands. Defaults to 120000. */
defaultTimeoutMs?: number;
}
// ── Fusion Plugin ────────────────────────────────────────────────────
export type PluginState = "installed" | "started" | "stopped" | "error";
@@ -229,6 +358,17 @@ export interface FusionPlugin {
uiSlots?: PluginUiSlotDefinition[];
/** Agent runtime registration for providing custom runtime implementations */
runtime?: PluginRuntimeRegistration;
/** Plugin-contributed skills surfaced by the skill resolver. */
skills?: PluginSkillContribution[];
/** Plugin-contributed workflow step templates. */
workflowSteps?: PluginWorkflowStepContribution[];
/** Plugin-contributed prompt injections. */
promptContributions?: PluginPromptContributions;
/** Plugin-managed setup metadata and lifecycle hooks. */
setup?: {
manifest: PluginSetupManifest;
hooks: PluginSetupHooks;
};
}
// ── Plugin Installation ───────────────────────────────────────────────
@@ -281,7 +421,7 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
// Required fields
if (!m.id || typeof m.id !== "string" || m.id.trim() === "") {
errors.push("id is required and must be a non-empty string");
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(m.id)) {
} else if (!SLUG_PATTERN.test(m.id)) {
errors.push("id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
}
@@ -344,7 +484,7 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
// runtimeId is required
if (!runtime.runtimeId || typeof runtime.runtimeId !== "string" || runtime.runtimeId.trim() === "") {
errors.push("runtime.runtimeId is required and must be a non-empty string");
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(runtime.runtimeId as string)) {
} else if (!SLUG_PATTERN.test(runtime.runtimeId as string)) {
errors.push("runtime.runtimeId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
}
@@ -364,12 +504,92 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
}
}
// Optional: plugin skill discovery metadata
if (m.skills !== undefined) {
if (!Array.isArray(m.skills)) {
errors.push("skills must be an array");
} else {
for (const [index, skill] of m.skills.entries()) {
if (!skill || typeof skill !== "object") {
errors.push(`skills[${index}] must be an object`);
continue;
}
const skillMeta = skill as Record<string, unknown>;
if (!skillMeta.skillId || typeof skillMeta.skillId !== "string" || skillMeta.skillId.trim() === "") {
errors.push(`skills[${index}].skillId is required and must be a non-empty string`);
} else if (!SLUG_PATTERN.test(skillMeta.skillId)) {
errors.push(`skills[${index}].skillId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`);
}
if (!skillMeta.name || typeof skillMeta.name !== "string" || skillMeta.name.trim() === "") {
errors.push(`skills[${index}].name is required and must be a non-empty string`);
}
}
}
}
// Optional: plugin workflow step discovery metadata
if (m.workflowSteps !== undefined) {
if (!Array.isArray(m.workflowSteps)) {
errors.push("workflowSteps must be an array");
} else {
for (const [index, step] of m.workflowSteps.entries()) {
if (!step || typeof step !== "object") {
errors.push(`workflowSteps[${index}] must be an object`);
continue;
}
const stepMeta = step as Record<string, unknown>;
if (!stepMeta.stepId || typeof stepMeta.stepId !== "string" || stepMeta.stepId.trim() === "") {
errors.push(`workflowSteps[${index}].stepId is required and must be a non-empty string`);
} else if (!SLUG_PATTERN.test(stepMeta.stepId)) {
errors.push(`workflowSteps[${index}].stepId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`);
}
if (!stepMeta.name || typeof stepMeta.name !== "string" || stepMeta.name.trim() === "") {
errors.push(`workflowSteps[${index}].name is required and must be a non-empty string`);
}
if (stepMeta.mode !== undefined && (typeof stepMeta.mode !== "string" || !["prompt", "script"].includes(stepMeta.mode))) {
errors.push(`workflowSteps[${index}].mode must be one of: prompt, script`);
}
}
}
}
// Optional: prompt surface metadata
if (m.promptSurfaces !== undefined) {
if (!Array.isArray(m.promptSurfaces)) {
errors.push("promptSurfaces must be an array");
} else {
for (const [index, surface] of m.promptSurfaces.entries()) {
if (typeof surface !== "string" || !PROMPT_CONTRIBUTION_SURFACES.includes(surface as PluginPromptSurface)) {
errors.push(`promptSurfaces[${index}] must be one of: ${PROMPT_CONTRIBUTION_SURFACES.join(", ")}`);
}
}
}
}
// Optional: setup manifest metadata
if (m.setup !== undefined) {
if (typeof m.setup !== "object" || m.setup === null) {
errors.push("setup must be an object");
} else {
const setup = m.setup as Record<string, unknown>;
if (!setup.binaryName || typeof setup.binaryName !== "string" || setup.binaryName.trim() === "") {
errors.push("setup.binaryName is required and must be a non-empty string");
}
if (!setup.description || typeof setup.description !== "string" || setup.description.trim() === "") {
errors.push("setup.description is required and must be a non-empty string");
}
if (setup.channel !== undefined && (typeof setup.channel !== "string" || !SETUP_CHANNELS.includes(setup.channel as (typeof SETUP_CHANNELS)[number]))) {
errors.push(`setup.channel must be one of: ${SETUP_CHANNELS.join(", ")}`);
}
if (setup.defaultTimeoutMs !== undefined && (typeof setup.defaultTimeoutMs !== "number" || !Number.isFinite(setup.defaultTimeoutMs) || setup.defaultTimeoutMs <= 0)) {
errors.push("setup.defaultTimeoutMs must be a positive finite number");
}
}
}
return {
valid: errors.length === 0,
errors,
};
}
// ── Re-export Task type for hook signatures ───────────────────────────
// The Task type is used in hook signatures; we import it via types.js
import type { Task } from "./types.js";