feat(FN-3071): add droid runtime plugin with event bridge, process manageme

This merge introduces a droid runtime plugin system with event bridge and process management (FN-3228), stabilizes plugin workspace verification gates and suppresses placeholder plugin shells in onboarding/settings flows (FN-3071), tokenizes danger/error styling in task detail components (FN-3350),

Fusion-Task-Id: FN-3071
This commit is contained in:
Fusion
2026-05-04 04:24:55 -07:00
committed by gsxdsm
parent 02d28359c2
commit b8d36506db
14 changed files with 343 additions and 17 deletions

View File

@@ -923,6 +923,16 @@ it("should return status from GET /status", async () => {
pnpm test
```
### Fusion Host Regression Coverage (Plugin Discovery / Load / Registration)
When a plugin changes host integration contracts, add or update host-side regression tests in this repository:
- **Core loader pipeline** (`packages/core/src/__tests__/plugin-loader.test.ts`): verify `PluginStore.registerPlugin()` → `PluginLoader.loadAllPlugins()` / `loadPlugin()` and assert `started` state transitions, manifest validation failures, disabled-plugin skip behavior, missing entrypoint failures, and `onLoad` error handling.
- **Dashboard API aggregation** (`packages/dashboard/src/__tests__/plugin-routes.test.ts`, `packages/dashboard/src/__tests__/plugin-routes.routes.test.ts`): verify plugin visibility via `GET /api/plugins`, `GET /api/plugins/ui-slots`, and `GET /api/plugins/runtimes` using standard loader/store aggregation (no plugin-specific route branches).
- **Dashboard slot consumers** (`packages/dashboard/app/components/__tests__/PluginSlot.test.tsx`, `packages/dashboard/app/hooks/__tests__/usePluginUiSlots.test.ts`): cover slot filtering, ordering, and rendering behavior for host slot IDs used by your plugin.
Keep this layer focused on **discovery/load/registration plumbing**. Deeper feature-flow regressions (Settings UX, onboarding UX, runtime execution/provider behavior) belong in dedicated follow-up suites, not in these plumbing tests.
---
## 12. Publishing Plugins

View File

@@ -104,6 +104,14 @@ describe("CLI Vitest workspace resolution", () => {
find: String(/^@fusion\/dashboard$/),
replacement: join(workspaceRoot, "packages", "dashboard", "src", "index.ts"),
},
{
find: String(/^@fusion-plugin-examples\/droid-runtime\/probe$/),
replacement: join(workspaceRoot, "plugins", "fusion-plugin-droid-runtime", "src", "probe.ts"),
},
{
find: String(/^@fusion-plugin-examples\/droid-runtime$/),
replacement: join(workspaceRoot, "plugins", "fusion-plugin-droid-runtime", "src", "index.ts"),
},
{
find: String(/^@fusion\/test-utils$/),
replacement: join(workspaceRoot, "packages", "core", "src", "__test-utils__", "workspace.ts"),
@@ -112,7 +120,10 @@ describe("CLI Vitest workspace resolution", () => {
);
for (const entry of normalized) {
expect(entry.replacement).toContain(`${join("packages", "")}`);
expect(
entry.replacement.includes(`${join("packages", "")}`) ||
entry.replacement.includes(`${join("plugins", "")}`),
).toBe(true);
expect(entry.replacement).toContain(`${join("src", "")}`);
expect(entry.replacement).not.toContain(`${join("dist", "")}`);
}

View File

@@ -17,6 +17,14 @@ export default defineConfig({
{ find: /^@fusion\/dashboard$/, replacement: resolve(__dirname, "../dashboard/src/index.ts") },
{ find: /^@fusion\/engine$/, replacement: resolve(__dirname, "../engine/src/index.ts") },
{ find: /^@fusion\/plugin-sdk$/, replacement: resolve(__dirname, "../plugin-sdk/src/index.ts") },
{
find: /^@fusion-plugin-examples\/droid-runtime\/probe$/,
replacement: resolve(__dirname, "../../plugins/fusion-plugin-droid-runtime/src/probe.ts"),
},
{
find: /^@fusion-plugin-examples\/droid-runtime$/,
replacement: resolve(__dirname, "../../plugins/fusion-plugin-droid-runtime/src/index.ts"),
},
{ find: /^@fusion\/test-utils$/, replacement: resolve(__dirname, "../core/src/__test-utils__/workspace.ts") },
],
},

View File

@@ -1,9 +1,18 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { PluginLoader } from "../plugin-loader.js";
vi.mock("@mariozechner/pi-ai", () => ({
AssistantMessageEventStream: class AssistantMessageEventStream {
push() {}
end() {}
},
calculateCost: () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }),
}));
import { PluginStore } from "../plugin-store.js";
import { setCreateAiSessionFactory } from "../ai-engine-loader.js";
import type { CreateAiSessionOptions, FusionPlugin, PluginManifest } from "../plugin-types.js";
@@ -105,6 +114,12 @@ function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-plugin-loader-test-"));
}
function droidPluginModulePath(): string {
return fileURLToPath(
new URL("../../../../plugins/fusion-plugin-droid-runtime/src/index.ts", import.meta.url),
);
}
// Mock TaskStore for testing
const mockTaskStore = {
logActivity: vi.fn(),
@@ -310,6 +325,64 @@ describe("PluginLoader", () => {
expect(loader.isPluginLoaded("load-test")).toBe(true);
});
it("loads the migrated Droid plugin through register→loadAllPlugins→loadPlugin pipeline", async () => {
await pluginStore.init();
const droidManifest = {
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime Plugin",
version: "0.1.0",
description: "Droid runtime plugin for Fusion",
runtime: {
runtimeId: "droid",
name: "Droid Runtime",
description: "Drives the Droid CLI for Fusion agents",
version: "0.1.0",
},
} as const;
await pluginStore.registerPlugin({
manifest: droidManifest,
path: droidPluginModulePath(),
});
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const loadAllResult = await loader.loadAllPlugins();
expect(loadAllResult).toEqual({ loaded: 1, errors: 0 });
expect(loader.isPluginLoaded("fusion-plugin-droid-runtime")).toBe(true);
const loaded = await loader.loadPlugin("fusion-plugin-droid-runtime");
expect(loaded.manifest.id).toBe("fusion-plugin-droid-runtime");
expect(loaded.state).toBe("started");
const installed = await pluginStore.getPlugin("fusion-plugin-droid-runtime");
expect(installed.state).toBe("started");
const slots = loader
.getPluginUiSlots()
.filter((entry) => entry.pluginId === "fusion-plugin-droid-runtime");
expect(slots.map((entry) => entry.slot.slotId)).toEqual([
"onboarding-provider-card",
"onboarding-setup-help",
"post-onboarding-recommendation",
"settings-provider-card",
]);
expect(slots[0]?.slot).toHaveProperty("label");
expect(slots[0]?.slot).toHaveProperty("componentPath");
const runtimes = loader
.getPluginRuntimes()
.filter((entry) => entry.pluginId === "fusion-plugin-droid-runtime");
expect(runtimes).toHaveLength(1);
expect(runtimes[0].runtime.metadata).toMatchObject({
runtimeId: "droid",
name: "Droid Runtime",
version: "0.1.0",
});
expect(typeof runtimes[0].runtime.factory).toBe("function");
});
it("updates plugin state to started", async () => {
await pluginStore.init();
@@ -415,6 +488,56 @@ describe("PluginLoader", () => {
);
});
it("fails when plugin module manifest is invalid", async () => {
await pluginStore.init();
const pluginDir = join(rootDir, "plugins");
const pluginPath = join(pluginDir, "invalid-manifest.js");
await mkdir(pluginDir, { recursive: true });
await writeFile(
pluginPath,
`
const plugin = {
manifest: { id: "invalid-manifest", version: "1.0.0" },
state: "installed",
hooks: {},
};
export default plugin;
`,
);
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "invalid-manifest" }),
path: pluginPath,
});
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
await expect(loader.loadPlugin("invalid-manifest")).rejects.toThrow(
"Invalid plugin manifest",
);
const stored = await pluginStore.getPlugin("invalid-manifest");
expect(stored.state).toBe("error");
});
it("fails when plugin entrypoint is missing", async () => {
await pluginStore.init();
const missingPath = join(rootDir, "plugins", "missing-entrypoint.js");
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "missing-entrypoint" }),
path: missingPath,
});
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
await expect(loader.loadPlugin("missing-entrypoint")).rejects.toThrow();
const stored = await pluginStore.getPlugin("missing-entrypoint");
expect(stored.state).toBe("error");
expect(stored.error).toBeTruthy();
});
it("error isolation - plugin crash during load doesn't crash loader", async () => {
await pluginStore.init();
@@ -487,6 +610,28 @@ describe("PluginLoader", () => {
expect(loader.isPluginLoaded("all-b")).toBe(true);
});
it("skips disabled plugins during loadAllPlugins", async () => {
await pluginStore.init();
const pluginDir = join(rootDir, "plugins");
const enabledPlugin = makePlugin(makeManifest({ id: "enabled-plugin" }));
const disabledPlugin = makePlugin(makeManifest({ id: "disabled-plugin" }));
const enabledPath = await writePluginModule(pluginDir, "enabled.js", enabledPlugin);
const disabledPath = await writePluginModule(pluginDir, "disabled.js", disabledPlugin);
await pluginStore.registerPlugin({ manifest: enabledPlugin.manifest, path: enabledPath });
await pluginStore.registerPlugin({ manifest: disabledPlugin.manifest, path: disabledPath });
await pluginStore.disablePlugin("disabled-plugin");
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const result = await loader.loadAllPlugins();
expect(result).toEqual({ loaded: 1, errors: 0 });
expect(loader.isPluginLoaded("enabled-plugin")).toBe(true);
expect(loader.isPluginLoaded("disabled-plugin")).toBe(false);
});
it("returns error count for failed plugins", async () => {
await pluginStore.init();

View File

@@ -8,6 +8,7 @@ export default defineConfig({
resolve: {
alias: {
"@fusion/test-utils": resolve(__dirname, "./src/__test-utils__/workspace.ts"),
"@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"),
},
},
test: {

View File

@@ -2112,7 +2112,7 @@ export function ModelOnboardingModal({
</div>
) : (
<>
<PluginSlot slotId="onboarding-provider-card" projectId={projectId} />
<PluginSlot slotId="onboarding-provider-card" projectId={projectId} renderPlaceholder={false} />
<section className="onboarding-provider-section" data-testid="onboarding-quick-start-providers">
<h3 className="onboarding-section-title">Quick start providers</h3>

View File

@@ -10,20 +10,16 @@ interface PluginSlotProps {
projectId?: string;
/** Optional plugin IDs to restrict rendering to a subset of matching entries */
pluginIds?: string[];
/** Render fallback shell placeholders while dynamic slot component mounting is unavailable */
renderPlaceholder?: boolean;
}
/**
* Renders plugin slot registrations for a host surface.
*
* Dynamic plugin component loading is not yet available, so this renders a
* lightweight non-technical placeholder while preserving plugin slot boundaries.
* Each rendered slot is wrapped in an ErrorBoundary to isolate failures from
* the parent dashboard UI.
*/
export function PluginSlot({ slotId, projectId, pluginIds }: PluginSlotProps): ReactNode {
export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = true }: PluginSlotProps): ReactNode {
const { getSlotsForId, loading, error } = usePluginUiSlots(projectId);
// Non-critical failure — no visible UI when loading, errored, or no matching slots
if (loading || error || !slotId) {
return null;
}
@@ -32,7 +28,7 @@ export function PluginSlot({ slotId, projectId, pluginIds }: PluginSlotProps): R
pluginIds && pluginIds.length > 0 ? pluginIds.includes(entry.pluginId) : true,
);
if (matchingEntries.length === 0) {
if (matchingEntries.length === 0 || !renderPlaceholder) {
return null;
}

View File

@@ -196,7 +196,7 @@ export function PostOnboardingRecommendations({
);
})}
</ul>
<PluginSlot slotId="post-onboarding-recommendation" />
<PluginSlot slotId="post-onboarding-recommendation" renderPlaceholder={false} />
</div>
</div>
<button

View File

@@ -5063,8 +5063,8 @@ export function SettingsModal({
</div>
) : (
<div className="auth-panel-body">
<PluginSlot slotId="settings-provider-card" projectId={projectId} />
<PluginSlot slotId="settings-integration-card" projectId={projectId} />
<PluginSlot slotId="settings-provider-card" projectId={projectId} renderPlaceholder={false} />
<PluginSlot slotId="settings-integration-card" projectId={projectId} renderPlaceholder={false} />
{!showAuthenticatedGroup && (
<div className="auth-section-hint">
Sign in to at least one provider to get started with AI models.

View File

@@ -76,7 +76,6 @@ describe("PluginSlot", () => {
const shells = container.querySelectorAll("[data-plugin-slot]");
expect(shells).toHaveLength(2);
// Verify both shells have correct attributes
expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-x");
expect(shells[0]).toHaveAttribute("data-slot-id", "board-column-footer");
expect(shells[1]).toHaveAttribute("data-plugin-id", "plugin-y");
@@ -120,6 +119,22 @@ describe("PluginSlot", () => {
expect(vi.mocked(usePluginUiSlots)).toHaveBeenCalledWith("proj-1");
});
it("suppresses placeholder rendering when renderPlaceholder is false", () => {
const entry = createSlotEntry("settings-provider-card", "plugin-droid");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry],
getSlotsForId: vi.fn(() => [entry]),
loading: false,
error: null,
});
const { container } = render(
<PluginSlot slotId="settings-provider-card" renderPlaceholder={false} />,
);
expect(container.firstChild).toBeNull();
});
it("returns null for empty string slotId", () => {
const getSlotsForId = vi.fn(() => []);
vi.mocked(usePluginUiSlots).mockReturnValue({

View File

@@ -197,6 +197,29 @@ describe("GET /plugins", () => {
});
});
it("includes the Droid plugin in installed plugin listings", async () => {
(pluginStore.listPlugins as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
...FAKE_PLUGIN,
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime Plugin",
version: "0.1.0",
},
]);
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({});
expect(res.body).toEqual([
expect.objectContaining({
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime Plugin",
version: "0.1.0",
}),
]);
});
it("filters plugins by enabled status", async () => {
const res = await GET(buildApp(), "/api/plugins?enabled=true");

View File

@@ -92,6 +92,7 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPluginUiSlots: vi.fn().mockReturnValue([]),
getPluginRuntimes: vi.fn().mockReturnValue([]),
getPluginDashboardViews: vi.fn().mockReturnValue([]),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
@@ -846,4 +847,111 @@ describe("GET /api/plugins/ui-slots", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("surfaces Droid plugin slot registrations through standard loader aggregation", async () => {
const droidSlots = [
{
pluginId: "fusion-plugin-droid-runtime",
slot: {
slotId: "settings-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/settings-provider-card.js",
order: 5,
},
},
{
pluginId: "fusion-plugin-droid-runtime",
slot: {
slotId: "onboarding-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/onboarding-provider-card.js",
placement: "after-default",
},
},
];
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue(droidSlots);
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
expect(res.status).toBe(200);
expect(pluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(1);
expect(res.body).toEqual([
{
pluginId: "fusion-plugin-droid-runtime",
slot: {
slotId: "settings-provider-card",
surface: "settings-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/settings-provider-card.js",
order: 5,
},
},
{
pluginId: "fusion-plugin-droid-runtime",
slot: {
slotId: "onboarding-provider-card",
surface: "onboarding-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/onboarding-provider-card.js",
placement: "after-default",
order: null,
},
},
]);
});
});
describe("GET /api/plugins/runtimes", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
let store: TaskStore;
beforeEach(() => {
vi.clearAllMocks();
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
return app;
}
it("includes Droid runtime metadata from plugin loader aggregation", async () => {
(pluginLoader.getPluginRuntimes as ReturnType<typeof vi.fn>).mockReturnValue([
{
pluginId: "fusion-plugin-droid-runtime",
runtime: {
metadata: {
runtimeId: "droid",
name: "Droid Runtime",
description: "Drives the Droid CLI for Fusion agents",
version: "0.1.0",
},
factory: vi.fn(),
},
},
]);
const res = await performGet(buildApp(), "/api/plugins/runtimes");
expect(res.status).toBe(200);
expect(pluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(1);
expect(res.body).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId: "fusion-plugin-droid-runtime",
runtimeId: "droid",
name: "Droid Runtime",
description: "Drives the Droid CLI for Fusion agents",
version: "0.1.0",
}),
]),
);
});
});

View File

@@ -13,6 +13,14 @@ export default defineConfig({
"@fusion/engine": resolve(__dirname, "../engine/src/index.ts"),
"@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"),
"@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"),
"@fusion-plugin-examples/droid-runtime/probe": resolve(
__dirname,
"../../plugins/fusion-plugin-droid-runtime/src/probe.ts",
),
"@fusion-plugin-examples/droid-runtime": resolve(
__dirname,
"../../plugins/fusion-plugin-droid-runtime/src/index.ts",
),
},
},
test: {

View File

@@ -21,6 +21,7 @@ const PROVIDER_ID = "droid-cli";
let cliValidationPromise: Promise<void> | undefined;
type DiscoveredModel = { id: string; name: string; reasoning: boolean; input: Array<"text" | "image">; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; maxTokens: number };
let discoveredModelsPromise: Promise<DiscoveredModel[]> | undefined;
type StreamSimpleHandler = NonNullable<Parameters<ExtensionAPI["registerProvider"]>[1]["streamSimple"]>;
function runCliValidationOnce(): Promise<void> {
if (cliValidationPromise) return cliValidationPromise;
@@ -112,7 +113,7 @@ export default function (pi: ExtensionAPI) {
apiKey: "unused",
api: "droid-cli",
models,
streamSimple: (model, context, options) => {
streamSimple: ((model, context, options) => {
const configPath = ensureMcpConfig(
pi,
(context as { tools?: ReadonlyArray<{ name: string; description: string; parameters: Record<string, unknown> }> }).tools,
@@ -121,8 +122,8 @@ export default function (pi: ExtensionAPI) {
model,
context as never,
{ ...(options ?? {}), mcpConfigPath: configPath } as never,
);
},
) as unknown as ReturnType<StreamSimpleHandler>;
}) as StreamSimpleHandler,
});
} catch (err) {
console.error("[droid-cli] Failed to register provider:", err);