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:
@@ -17,6 +17,10 @@ A comprehensive guide to creating Fusion plugins that extend the task board with
|
|||||||
11. [Testing Plugins](#11-testing-plugins)
|
11. [Testing Plugins](#11-testing-plugins)
|
||||||
12. [Publishing Plugins](#12-publishing-plugins)
|
12. [Publishing Plugins](#12-publishing-plugins)
|
||||||
13. [Example Plugins](#13-example-plugins)
|
13. [Example Plugins](#13-example-plugins)
|
||||||
|
14. [Registering Skills](#14-registering-skills)
|
||||||
|
15. [Registering Workflow Steps](#15-registering-workflow-steps)
|
||||||
|
16. [Plugin Prompt Contributions](#16-plugin-prompt-contributions)
|
||||||
|
17. [Plugin Binary Setup Hooks](#17-plugin-binary-setup-hooks)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1006,3 +1010,115 @@ export default definePlugin({
|
|||||||
---
|
---
|
||||||
|
|
||||||
For more information, see the [Plugin SDK Reference](../packages/plugin-sdk/src/index.ts).
|
For more information, see the [Plugin SDK Reference](../packages/plugin-sdk/src/index.ts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Registering Skills
|
||||||
|
|
||||||
|
Plugins can contribute reusable skills that are surfaced in agent sessions through Fusion's skill-selection flow.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { PluginSkillContribution } from "@fusion/plugin-sdk";
|
||||||
|
|
||||||
|
const skills: PluginSkillContribution[] = [
|
||||||
|
{
|
||||||
|
skillId: "web-research",
|
||||||
|
name: "Web Research",
|
||||||
|
description: "Finds and summarizes web sources for a task",
|
||||||
|
skillFiles: ["skills/web-research/SKILL.md"],
|
||||||
|
enabled: true,
|
||||||
|
triggerPatterns: ["research", "search the web", "find sources"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
`skillFiles` are relative to the plugin root. `skillId` must be kebab-case.
|
||||||
|
|
||||||
|
## 15. Registering Workflow Steps
|
||||||
|
|
||||||
|
Plugins can ship workflow step templates that users can enable like built-in quality gates.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { PluginWorkflowStepContribution } from "@fusion/plugin-sdk";
|
||||||
|
|
||||||
|
const workflowSteps: PluginWorkflowStepContribution[] = [
|
||||||
|
{
|
||||||
|
stepId: "strict-review",
|
||||||
|
name: "Strict Review",
|
||||||
|
description: "Run an AI review with strict failure criteria",
|
||||||
|
mode: "prompt",
|
||||||
|
phase: "pre-merge",
|
||||||
|
prompt: "Review this task for correctness, regressions, and missing tests.",
|
||||||
|
toolMode: "readonly",
|
||||||
|
defaultOn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: "smoke-build",
|
||||||
|
name: "Smoke Build",
|
||||||
|
description: "Build package before merge",
|
||||||
|
mode: "script",
|
||||||
|
scriptName: "build",
|
||||||
|
toolMode: "coding",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `mode: "prompt" | "script"` and `toolMode: "readonly" | "coding"`.
|
||||||
|
|
||||||
|
## 16. Plugin Prompt Contributions
|
||||||
|
|
||||||
|
Prompt contributions let a plugin inject additional instructions into specific prompt surfaces.
|
||||||
|
|
||||||
|
Supported surfaces:
|
||||||
|
- `executor-system`
|
||||||
|
- `executor-task`
|
||||||
|
- `triage`
|
||||||
|
- `reviewer`
|
||||||
|
- `heartbeat`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { PluginPromptContributions } from "@fusion/plugin-sdk";
|
||||||
|
|
||||||
|
const promptContributions: PluginPromptContributions = {
|
||||||
|
enabledByDefault: false,
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
surface: "reviewer",
|
||||||
|
position: "append",
|
||||||
|
content: "Always call out missing tests and unsafe assumptions.",
|
||||||
|
condition: "Only for backend code changes",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `enabledByDefault: false` when contributions should require explicit opt-in.
|
||||||
|
|
||||||
|
## 17. Plugin Binary Setup Hooks
|
||||||
|
|
||||||
|
Plugins can expose setup metadata and lifecycle hooks for optional binaries or runtimes.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { PluginSetupCheckResult, PluginSetupHooks, PluginSetupManifest } from "@fusion/plugin-sdk";
|
||||||
|
|
||||||
|
const setupManifest: PluginSetupManifest = {
|
||||||
|
binaryName: "agent-browser",
|
||||||
|
description: "Headless browser runtime for web-enabled agents",
|
||||||
|
channel: "stable",
|
||||||
|
defaultTimeoutMs: 120_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupHooks: PluginSetupHooks = {
|
||||||
|
async checkSetup(ctx): Promise<PluginSetupCheckResult> {
|
||||||
|
return { status: "not-installed" };
|
||||||
|
},
|
||||||
|
async install(ctx) {
|
||||||
|
// Use async process execution with timeout; never use execSync.
|
||||||
|
},
|
||||||
|
async uninstall(ctx) {
|
||||||
|
// Remove managed binary/runtime artifacts.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`checkSetup` is required. `install` and `uninstall` are optional.
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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";
|
import { validatePluginManifest } from "../plugin-types.js";
|
||||||
|
|
||||||
describe("validatePluginManifest", () => {
|
describe("validatePluginManifest", () => {
|
||||||
@@ -991,3 +1000,187 @@ describe("PluginRuntimeRegistration", () => {
|
|||||||
expect(typeof registration.factory).toBe("function");
|
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,
|
PluginRuntimeRegistration,
|
||||||
PluginContext,
|
PluginContext,
|
||||||
PluginLogger,
|
PluginLogger,
|
||||||
|
PluginSkillContribution,
|
||||||
|
PluginWorkflowStepContribution,
|
||||||
|
PluginPromptSurface,
|
||||||
|
PluginPromptContribution,
|
||||||
|
PluginPromptContributions,
|
||||||
|
PluginSetupStatus,
|
||||||
|
PluginSetupCheckResult,
|
||||||
|
PluginSetupHooks,
|
||||||
|
PluginSetupManifest,
|
||||||
FusionPlugin,
|
FusionPlugin,
|
||||||
PluginState,
|
PluginState,
|
||||||
PluginInstallation,
|
PluginInstallation,
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TaskStore } from "./store.js";
|
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 ───────────────────────────────────────────────────
|
// ── Plugin Manifest ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -39,6 +44,14 @@ export interface PluginManifest {
|
|||||||
settingsSchema?: Record<string, PluginSettingSchema>;
|
settingsSchema?: Record<string, PluginSettingSchema>;
|
||||||
/** Optional agent runtime metadata for discovery (runtime factory is in FusionPlugin.runtime) */
|
/** Optional agent runtime metadata for discovery (runtime factory is in FusionPlugin.runtime) */
|
||||||
runtime?: PluginRuntimeManifestMetadata;
|
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 ──────────────────────────────────────────────
|
// ── Plugin Setting Schema ──────────────────────────────────────────────
|
||||||
@@ -206,6 +219,122 @@ export interface PluginRuntimeRegistration {
|
|||||||
factory: PluginRuntimeFactory;
|
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 ────────────────────────────────────────────────────
|
// ── Fusion Plugin ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type PluginState = "installed" | "started" | "stopped" | "error";
|
export type PluginState = "installed" | "started" | "stopped" | "error";
|
||||||
@@ -229,6 +358,17 @@ export interface FusionPlugin {
|
|||||||
uiSlots?: PluginUiSlotDefinition[];
|
uiSlots?: PluginUiSlotDefinition[];
|
||||||
/** Agent runtime registration for providing custom runtime implementations */
|
/** Agent runtime registration for providing custom runtime implementations */
|
||||||
runtime?: PluginRuntimeRegistration;
|
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 ───────────────────────────────────────────────
|
// ── Plugin Installation ───────────────────────────────────────────────
|
||||||
@@ -281,7 +421,7 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
|
|||||||
// Required fields
|
// Required fields
|
||||||
if (!m.id || typeof m.id !== "string" || m.id.trim() === "") {
|
if (!m.id || typeof m.id !== "string" || m.id.trim() === "") {
|
||||||
errors.push("id is required and must be a non-empty string");
|
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)");
|
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
|
// runtimeId is required
|
||||||
if (!runtime.runtimeId || typeof runtime.runtimeId !== "string" || runtime.runtimeId.trim() === "") {
|
if (!runtime.runtimeId || typeof runtime.runtimeId !== "string" || runtime.runtimeId.trim() === "") {
|
||||||
errors.push("runtime.runtimeId is required and must be a non-empty string");
|
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)");
|
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 {
|
return {
|
||||||
valid: errors.length === 0,
|
valid: errors.length === 0,
|
||||||
errors,
|
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);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plugin-homepage {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.plugin-homepage a {
|
.plugin-homepage a {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
color: var(--color-info);
|
color: var(--color-info);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-detail-section-heading {
|
.plugin-detail-section-heading {
|
||||||
@@ -258,6 +265,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
row-gap: var(--space-xs);
|
||||||
|
flex-wrap: wrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +321,12 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.plugin-bundled-runtime-status {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.plugin-bundled-runtime-status--installed {
|
.plugin-bundled-runtime-status--installed {
|
||||||
background: var(--status-done-bg);
|
background: var(--status-done-bg);
|
||||||
color: var(--done);
|
color: var(--done);
|
||||||
@@ -323,6 +338,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.plugin-manager,
|
||||||
|
.plugin-manager-detail {
|
||||||
|
padding-inline: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.plugin-manager-detail-header {
|
.plugin-manager-detail-header {
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ interface BundledRuntimePlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BUNDLED_RUNTIME_PLUGINS: 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",
|
id: "fusion-plugin-hermes-runtime",
|
||||||
name: "Hermes Runtime",
|
name: "Hermes Runtime",
|
||||||
@@ -351,6 +357,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
|||||||
</label>
|
</label>
|
||||||
{schema.type === "string" && !schema.multiline && (
|
{schema.type === "string" && !schema.multiline && (
|
||||||
<input
|
<input
|
||||||
|
className="input"
|
||||||
type="text"
|
type="text"
|
||||||
id={`setting-${key}`}
|
id={`setting-${key}`}
|
||||||
value={(pluginSettings[key] as string) ?? ""}
|
value={(pluginSettings[key] as string) ?? ""}
|
||||||
@@ -361,6 +368,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
|||||||
)}
|
)}
|
||||||
{schema.type === "string" && schema.multiline && (
|
{schema.type === "string" && schema.multiline && (
|
||||||
<textarea
|
<textarea
|
||||||
|
className="input"
|
||||||
id={`setting-${key}`}
|
id={`setting-${key}`}
|
||||||
rows={4}
|
rows={4}
|
||||||
value={(pluginSettings[key] as string) ?? ""}
|
value={(pluginSettings[key] as string) ?? ""}
|
||||||
@@ -371,6 +379,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
|||||||
)}
|
)}
|
||||||
{schema.type === "password" && (
|
{schema.type === "password" && (
|
||||||
<input
|
<input
|
||||||
|
className="input"
|
||||||
type="password"
|
type="password"
|
||||||
id={`setting-${key}`}
|
id={`setting-${key}`}
|
||||||
value={(pluginSettings[key] as string) ?? ""}
|
value={(pluginSettings[key] as string) ?? ""}
|
||||||
@@ -381,6 +390,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
|||||||
)}
|
)}
|
||||||
{schema.type === "number" && (
|
{schema.type === "number" && (
|
||||||
<input
|
<input
|
||||||
|
className="input"
|
||||||
type="number"
|
type="number"
|
||||||
id={`setting-${key}`}
|
id={`setting-${key}`}
|
||||||
value={(pluginSettings[key] as number) ?? ""}
|
value={(pluginSettings[key] as number) ?? ""}
|
||||||
@@ -400,6 +410,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
|||||||
)}
|
)}
|
||||||
{schema.type === "enum" && (
|
{schema.type === "enum" && (
|
||||||
<select
|
<select
|
||||||
|
className="select"
|
||||||
id={`setting-${key}`}
|
id={`setting-${key}`}
|
||||||
value={(pluginSettings[key] as string) ?? ""}
|
value={(pluginSettings[key] as string) ?? ""}
|
||||||
onChange={(e) => setPluginSettings({ ...pluginSettings, [key]: e.target.value })}
|
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) => (
|
{(pluginSettings[key] as unknown[] | undefined)?.map((item, index) => (
|
||||||
<div key={index} className="plugin-settings-array-item">
|
<div key={index} className="plugin-settings-array-item">
|
||||||
<input
|
<input
|
||||||
|
className="input"
|
||||||
type={schema.itemType === "number" ? "number" : "text"}
|
type={schema.itemType === "number" ? "number" : "text"}
|
||||||
value={(item as string | number) ?? ""}
|
value={(item as string | number) ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -497,11 +509,11 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const installedPluginIds = new Set(plugins.map((plugin) => plugin.id));
|
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,
|
// Keep bundled plugins in the main list once installed so users can always
|
||||||
// so we exclude them from the main plugin list to avoid duplication.
|
// access enable/disable, settings, and uninstall controls.
|
||||||
const bundledPluginIds = new Set(BUNDLED_RUNTIME_PLUGINS.map((p) => p.id));
|
const installedPlugins = plugins;
|
||||||
const userInstalledPlugins = plugins.filter((p) => !bundledPluginIds.has(p.id));
|
|
||||||
|
|
||||||
const renderBundledRuntimeSection = () => (
|
const renderBundledRuntimeSection = () => (
|
||||||
<section className="plugin-bundled-runtime-section" aria-label="Bundled Runtime Plugins">
|
<section className="plugin-bundled-runtime-section" aria-label="Bundled Runtime Plugins">
|
||||||
@@ -529,11 +541,20 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={`btn ${isInstalled ? "btn-secondary" : "btn-primary"} btn-sm`}
|
className={`btn ${isInstalled ? "btn-secondary" : "btn-primary"} btn-sm`}
|
||||||
onClick={() => handleInstallBundledRuntimePlugin(bundledPlugin)}
|
onClick={() => {
|
||||||
disabled={isInstalled || installingBundledPluginId === bundledPlugin.id}
|
if (isInstalled) {
|
||||||
|
const installedPlugin = installedPluginsById.get(bundledPlugin.id);
|
||||||
|
if (installedPlugin) {
|
||||||
|
void handleSelectPlugin(installedPlugin);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void handleInstallBundledRuntimePlugin(bundledPlugin);
|
||||||
|
}}
|
||||||
|
disabled={installingBundledPluginId === bundledPlugin.id}
|
||||||
>
|
>
|
||||||
{isInstalled
|
{isInstalled
|
||||||
? "Installed"
|
? "Manage"
|
||||||
: installingBundledPluginId === bundledPlugin.id
|
: installingBundledPluginId === bundledPlugin.id
|
||||||
? "Installing..."
|
? "Installing..."
|
||||||
: `Install ${bundledPlugin.name}`}
|
: `Install ${bundledPlugin.name}`}
|
||||||
@@ -592,7 +613,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
|||||||
<div className="settings-empty-state">Loading plugins...</div>
|
<div className="settings-empty-state">Loading plugins...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{userInstalledPlugins.length === 0 ? (
|
{installedPlugins.length === 0 ? (
|
||||||
<div className="settings-empty-state">
|
<div className="settings-empty-state">
|
||||||
<Package size={32} className="text-muted" />
|
<Package size={32} className="text-muted" />
|
||||||
<p>No plugins installed.</p>
|
<p>No plugins installed.</p>
|
||||||
@@ -600,7 +621,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="plugin-list">
|
<div className="plugin-list">
|
||||||
{userInstalledPlugins.map((plugin) => (
|
{installedPlugins.map((plugin) => (
|
||||||
<div key={plugin.id} className="plugin-item">
|
<div key={plugin.id} className="plugin-item">
|
||||||
<div className="plugin-info">
|
<div className="plugin-info">
|
||||||
<span className="plugin-name">{plugin.name}</span>
|
<span className="plugin-name">{plugin.name}</span>
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ describe("PluginManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("No plugins installed.")).toBeTruthy();
|
expect(screen.getByText("No plugins installed.")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Agent Browser Runtime")).toBeTruthy();
|
||||||
expect(screen.getByText("Hermes Runtime")).toBeTruthy();
|
expect(screen.getByText("Hermes Runtime")).toBeTruthy();
|
||||||
expect(screen.getByText("Paperclip Runtime")).toBeTruthy();
|
expect(screen.getByText("Paperclip Runtime")).toBeTruthy();
|
||||||
expect(screen.getByText("OpenClaw 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([
|
vi.mocked(fetchPlugins).mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
...mockPlugins[0],
|
...mockPlugins[0],
|
||||||
@@ -521,9 +522,17 @@ describe("PluginManager", () => {
|
|||||||
const hermesCard = screen.getByText("Hermes Runtime").closest(".plugin-bundled-runtime-item");
|
const hermesCard = screen.getByText("Hermes Runtime").closest(".plugin-bundled-runtime-item");
|
||||||
expect(hermesCard).toBeTruthy();
|
expect(hermesCard).toBeTruthy();
|
||||||
|
|
||||||
const installButton = within(hermesCard as HTMLElement).getByRole("button", { name: /^Installed$/i });
|
const manageButton = within(hermesCard as HTMLElement).getByRole("button", { name: /^Manage$/i });
|
||||||
expect(installButton).toBeDisabled();
|
expect(manageButton).not.toBeDisabled();
|
||||||
expect(within(hermesCard as HTMLElement).getAllByText("Installed").length).toBeGreaterThanOrEqual(1);
|
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", () => {
|
describe("SSE Live Updates", () => {
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ export type {
|
|||||||
PluginRuntimeRegistration,
|
PluginRuntimeRegistration,
|
||||||
PluginContext,
|
PluginContext,
|
||||||
PluginLogger,
|
PluginLogger,
|
||||||
|
PluginSkillContribution,
|
||||||
|
PluginWorkflowStepContribution,
|
||||||
|
PluginPromptSurface,
|
||||||
|
PluginPromptContribution,
|
||||||
|
PluginPromptContributions,
|
||||||
|
PluginSetupStatus,
|
||||||
|
PluginSetupCheckResult,
|
||||||
|
PluginSetupHooks,
|
||||||
|
PluginSetupManifest,
|
||||||
FusionPlugin,
|
FusionPlugin,
|
||||||
PluginState,
|
PluginState,
|
||||||
PluginInstallation,
|
PluginInstallation,
|
||||||
|
|||||||
Reference in New Issue
Block a user