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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,15 @@ export type {
|
||||
PluginRuntimeRegistration,
|
||||
PluginContext,
|
||||
PluginLogger,
|
||||
PluginSkillContribution,
|
||||
PluginWorkflowStepContribution,
|
||||
PluginPromptSurface,
|
||||
PluginPromptContribution,
|
||||
PluginPromptContributions,
|
||||
PluginSetupStatus,
|
||||
PluginSetupCheckResult,
|
||||
PluginSetupHooks,
|
||||
PluginSetupManifest,
|
||||
FusionPlugin,
|
||||
PluginState,
|
||||
PluginInstallation,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -170,12 +170,19 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.plugin-homepage {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.plugin-homepage a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--color-info);
|
||||
font-size: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.plugin-detail-section-heading {
|
||||
@@ -258,6 +265,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
row-gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -312,6 +321,12 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.plugin-bundled-runtime-status {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-bundled-runtime-status--installed {
|
||||
background: var(--status-done-bg);
|
||||
color: var(--done);
|
||||
@@ -323,6 +338,11 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.plugin-manager,
|
||||
.plugin-manager-detail {
|
||||
padding-inline: var(--space-sm);
|
||||
}
|
||||
|
||||
.plugin-manager-detail-header {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ interface BundledRuntimePlugin {
|
||||
}
|
||||
|
||||
const BUNDLED_RUNTIME_PLUGINS: BundledRuntimePlugin[] = [
|
||||
{
|
||||
id: "fusion-plugin-agent-browser-runtime",
|
||||
name: "Agent Browser Runtime",
|
||||
path: "./plugins/fusion-plugin-agent-browser-runtime",
|
||||
experimental: true,
|
||||
},
|
||||
{
|
||||
id: "fusion-plugin-hermes-runtime",
|
||||
name: "Hermes Runtime",
|
||||
@@ -351,6 +357,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
</label>
|
||||
{schema.type === "string" && !schema.multiline && (
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
id={`setting-${key}`}
|
||||
value={(pluginSettings[key] as string) ?? ""}
|
||||
@@ -361,6 +368,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
)}
|
||||
{schema.type === "string" && schema.multiline && (
|
||||
<textarea
|
||||
className="input"
|
||||
id={`setting-${key}`}
|
||||
rows={4}
|
||||
value={(pluginSettings[key] as string) ?? ""}
|
||||
@@ -371,6 +379,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
)}
|
||||
{schema.type === "password" && (
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
id={`setting-${key}`}
|
||||
value={(pluginSettings[key] as string) ?? ""}
|
||||
@@ -381,6 +390,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
)}
|
||||
{schema.type === "number" && (
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
id={`setting-${key}`}
|
||||
value={(pluginSettings[key] as number) ?? ""}
|
||||
@@ -400,6 +410,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
)}
|
||||
{schema.type === "enum" && (
|
||||
<select
|
||||
className="select"
|
||||
id={`setting-${key}`}
|
||||
value={(pluginSettings[key] as string) ?? ""}
|
||||
onChange={(e) => setPluginSettings({ ...pluginSettings, [key]: e.target.value })}
|
||||
@@ -416,6 +427,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
{(pluginSettings[key] as unknown[] | undefined)?.map((item, index) => (
|
||||
<div key={index} className="plugin-settings-array-item">
|
||||
<input
|
||||
className="input"
|
||||
type={schema.itemType === "number" ? "number" : "text"}
|
||||
value={(item as string | number) ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -497,11 +509,11 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
}
|
||||
|
||||
const installedPluginIds = new Set(plugins.map((plugin) => plugin.id));
|
||||
const installedPluginsById = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||
|
||||
// Bundled runtime plugin IDs — these are shown in the dedicated section below,
|
||||
// so we exclude them from the main plugin list to avoid duplication.
|
||||
const bundledPluginIds = new Set(BUNDLED_RUNTIME_PLUGINS.map((p) => p.id));
|
||||
const userInstalledPlugins = plugins.filter((p) => !bundledPluginIds.has(p.id));
|
||||
// Keep bundled plugins in the main list once installed so users can always
|
||||
// access enable/disable, settings, and uninstall controls.
|
||||
const installedPlugins = plugins;
|
||||
|
||||
const renderBundledRuntimeSection = () => (
|
||||
<section className="plugin-bundled-runtime-section" aria-label="Bundled Runtime Plugins">
|
||||
@@ -529,11 +541,20 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
</div>
|
||||
<button
|
||||
className={`btn ${isInstalled ? "btn-secondary" : "btn-primary"} btn-sm`}
|
||||
onClick={() => handleInstallBundledRuntimePlugin(bundledPlugin)}
|
||||
disabled={isInstalled || installingBundledPluginId === bundledPlugin.id}
|
||||
onClick={() => {
|
||||
if (isInstalled) {
|
||||
const installedPlugin = installedPluginsById.get(bundledPlugin.id);
|
||||
if (installedPlugin) {
|
||||
void handleSelectPlugin(installedPlugin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void handleInstallBundledRuntimePlugin(bundledPlugin);
|
||||
}}
|
||||
disabled={installingBundledPluginId === bundledPlugin.id}
|
||||
>
|
||||
{isInstalled
|
||||
? "Installed"
|
||||
? "Manage"
|
||||
: installingBundledPluginId === bundledPlugin.id
|
||||
? "Installing..."
|
||||
: `Install ${bundledPlugin.name}`}
|
||||
@@ -592,7 +613,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
<div className="settings-empty-state">Loading plugins...</div>
|
||||
) : (
|
||||
<>
|
||||
{userInstalledPlugins.length === 0 ? (
|
||||
{installedPlugins.length === 0 ? (
|
||||
<div className="settings-empty-state">
|
||||
<Package size={32} className="text-muted" />
|
||||
<p>No plugins installed.</p>
|
||||
@@ -600,7 +621,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-list">
|
||||
{userInstalledPlugins.map((plugin) => (
|
||||
{installedPlugins.map((plugin) => (
|
||||
<div key={plugin.id} className="plugin-item">
|
||||
<div className="plugin-info">
|
||||
<span className="plugin-name">{plugin.name}</span>
|
||||
|
||||
@@ -226,6 +226,7 @@ describe("PluginManager", () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText("No plugins installed.")).toBeTruthy();
|
||||
expect(screen.getByText("Agent Browser Runtime")).toBeTruthy();
|
||||
expect(screen.getByText("Hermes Runtime")).toBeTruthy();
|
||||
expect(screen.getByText("Paperclip Runtime")).toBeTruthy();
|
||||
expect(screen.getByText("OpenClaw Runtime")).toBeTruthy();
|
||||
@@ -503,7 +504,7 @@ describe("PluginManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows installed status and disables bundled runtime install button when already installed", async () => {
|
||||
it("keeps bundled plugin manageable after install", async () => {
|
||||
vi.mocked(fetchPlugins).mockResolvedValueOnce([
|
||||
{
|
||||
...mockPlugins[0],
|
||||
@@ -521,9 +522,17 @@ describe("PluginManager", () => {
|
||||
const hermesCard = screen.getByText("Hermes Runtime").closest(".plugin-bundled-runtime-item");
|
||||
expect(hermesCard).toBeTruthy();
|
||||
|
||||
const installButton = within(hermesCard as HTMLElement).getByRole("button", { name: /^Installed$/i });
|
||||
expect(installButton).toBeDisabled();
|
||||
const manageButton = within(hermesCard as HTMLElement).getByRole("button", { name: /^Manage$/i });
|
||||
expect(manageButton).not.toBeDisabled();
|
||||
expect(within(hermesCard as HTMLElement).getAllByText("Installed").length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await userEvent.click(manageButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchPluginSettings).toHaveBeenCalledWith("fusion-plugin-hermes-runtime", undefined);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("plugin-manager-detail")).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("SSE Live Updates", () => {
|
||||
|
||||
@@ -54,6 +54,15 @@ export type {
|
||||
PluginRuntimeRegistration,
|
||||
PluginContext,
|
||||
PluginLogger,
|
||||
PluginSkillContribution,
|
||||
PluginWorkflowStepContribution,
|
||||
PluginPromptSurface,
|
||||
PluginPromptContribution,
|
||||
PluginPromptContributions,
|
||||
PluginSetupStatus,
|
||||
PluginSetupCheckResult,
|
||||
PluginSetupHooks,
|
||||
PluginSetupManifest,
|
||||
FusionPlugin,
|
||||
PluginState,
|
||||
PluginInstallation,
|
||||
|
||||
Reference in New Issue
Block a user