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:
@@ -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
|
||||
|
||||
@@ -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", "")}`);
|
||||
}
|
||||
|
||||
@@ -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") },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ export function PostOnboardingRecommendations({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<PluginSlot slotId="post-onboarding-recommendation" />
|
||||
<PluginSlot slotId="post-onboarding-recommendation" renderPlaceholder={false} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user