feat(FN-3328): refactor droid-cli into compatibility shim backed by fusion-

Merged FN-3328: Refactored the droid integration by converting `droid-cli` into a lightweight compatibility shim that delegates to `fusion-plugin-droid-runtime`, reducing droid-cli by ~5,400 lines of code while moving the actual runtime logic into the plugin scaffold. Updated the plugin loader to al

Fusion-Task-Id: FN-3328
This commit is contained in:
Fusion
2026-05-04 17:04:31 -07:00
committed by gsxdsm
parent 6ea1fb01e4
commit 7e1768b1b1
21 changed files with 310 additions and 5700 deletions

View File

@@ -1,19 +1,38 @@
# Droid Runtime Plugin
First-class Droid plugin package under `plugins/fusion-plugin-droid-runtime`.
First-class Droid runtime/provider plugin (`@fusion-plugin-examples/droid-runtime`).
## What this package is
## Purpose
- Canonical home for Droid runtime/provider plugin code.
- Installable from dashboard **Settings → Plugins → Fusion Plugins → Bundled Plugins** via path `./plugins/fusion-plugin-droid-runtime`.
- Plugin identity is stable: `fusion-plugin-droid-runtime`.
This package is the canonical home for Droid-specific runtime behavior, including:
- provider id `droid-cli`
- model discovery + normalization
- CLI subprocess streaming + session resume
- MCP tool bridge + thinking effort mapping
- probe contract via `probeDroidBinary`
## Migration context
- FN-3261 established this package as the official landing zone so Droid plugin work does not need ad-hoc layouts.
- Runtime/provider logic that used to live in `packages/droid-cli` is now owned here; `@fusion/droid-cli` remains a compatibility shim surface where needed.
## Key IDs
## Runtime + Provider
- Runtime ID: `droid`
- Provider ID: `droid-cli`
- Display name: `Droid Runtime`
- Provider surface preserved: `Factory AI — via Droid CLI` (`droid-cli`)
Core implementation files live in `src/`:
- `runtime-adapter.ts`
- `provider.ts`
- `process-manager.ts`
- `probe.ts`
- prompt/tool/thinking/control helpers
## Dashboard UI contribution surfaces
The plugin registers `uiSlots` for:
- `settings-provider-card`
- `settings-integration-card`
- `onboarding-provider-card`
- `onboarding-setup-help`
- `post-onboarding-recommendation`
## Compatibility with `@fusion/droid-cli`
`packages/droid-cli` is now a thin compatibility shim. It keeps the historical pi-extension entrypoint, but delegates runtime/provider behavior to this plugin package.

View File

@@ -13,6 +13,7 @@ describe("droid runtime plugin index", () => {
const slots = plugin.uiSlots?.map((s) => s.slotId) ?? [];
expect(slots).toEqual(expect.arrayContaining([
"settings-provider-card",
"settings-integration-card",
"onboarding-provider-card",
"onboarding-setup-help",
"post-onboarding-recommendation",
@@ -25,6 +26,7 @@ describe("droid runtime plugin index", () => {
slotId: "settings-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/settings-provider-card.js",
order: 10,
});
});

View File

@@ -1,22 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
const spawnMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", () => ({
spawn: vi.fn(() => {
const proc = new EventEmitter() as any;
proc.stdout = new PassThrough();
proc.stderr = new PassThrough();
queueMicrotask(() => proc.emit("error", Object.assign(new Error("not found"), { code: "ENOENT" })));
return proc;
}),
spawn: spawnMock,
}));
import { probeDroidBinary } from "../probe.js";
describe("probeDroidBinary", () => {
beforeEach(() => {
spawnMock.mockReset();
});
it("returns unavailable when binary is missing", async () => {
spawnMock.mockImplementationOnce(() => {
const proc = new EventEmitter() as any;
proc.stdout = new PassThrough();
proc.stderr = new PassThrough();
queueMicrotask(() => proc.emit("error", Object.assign(new Error("not found"), { code: "ENOENT" })));
return proc;
});
const result = await probeDroidBinary({ timeoutMs: 10 });
expect(result.available).toBe(false);
expect(result.reason).toContain("not found");
});
it("returns available and version on success", async () => {
spawnMock.mockImplementationOnce(() => {
const proc = new EventEmitter() as any;
proc.stdout = new PassThrough();
proc.stderr = new PassThrough();
queueMicrotask(() => {
proc.stdout.write("droid 1.2.3\n");
proc.emit("close", 0);
});
return proc;
});
const result = await probeDroidBinary();
expect(result.available).toBe(true);
expect(result.version).toBe("droid 1.2.3");
});
it("uses custom binary path", async () => {
spawnMock.mockImplementationOnce(() => {
const proc = new EventEmitter() as any;
proc.stdout = new PassThrough();
proc.stderr = new PassThrough();
queueMicrotask(() => proc.emit("close", 1));
return proc;
});
await probeDroidBinary({ binaryPath: "/custom/droid" });
expect(spawnMock).toHaveBeenCalledWith("/custom/droid", ["--version"], expect.anything());
});
});

View File

@@ -1,10 +1,115 @@
import { describe, expect, it, vi } from "vitest";
import * as processManager from "../process-manager.js";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
describe("provider dependencies", () => {
it("deduplicates discovered model ids", async () => {
vi.spyOn(processManager, "discoverDroidModels").mockResolvedValue(["a", "b", "a"]);
const ids = Array.from(new Set(await processManager.discoverDroidModels()));
expect(ids).toEqual(["a", "b"]);
const mocks = vi.hoisted(() => ({
spawnDroid: vi.fn(),
writeUserMessage: vi.fn(),
captureStderr: vi.fn(() => () => ""),
registerProcess: vi.fn(),
cleanupProcess: vi.fn(),
forceKillProcess: vi.fn(),
cleanupSystemPromptFile: vi.fn(),
buildDroidSpawnArgs: vi.fn(() => ["--model", "droid-pro"]),
parseLine: vi.fn(),
bridgeHandleEvent: vi.fn(),
bridgeGetOutput: vi.fn(() => ({
role: "assistant",
content: [],
api: "droid-cli",
provider: "droid-cli",
model: "droid-pro",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop",
timestamp: 0,
})),
}));
vi.mock("../process-manager.js", () => ({
spawnDroid: mocks.spawnDroid,
writeUserMessage: mocks.writeUserMessage,
captureStderr: mocks.captureStderr,
registerProcess: mocks.registerProcess,
cleanupProcess: mocks.cleanupProcess,
forceKillProcess: mocks.forceKillProcess,
cleanupSystemPromptFile: mocks.cleanupSystemPromptFile,
buildDroidSpawnArgs: mocks.buildDroidSpawnArgs,
}));
vi.mock("../stream-parser.js", () => ({ parseLine: mocks.parseLine }));
vi.mock("../event-bridge.js", () => ({
createEventBridge: () => ({ handleEvent: mocks.bridgeHandleEvent, getOutput: mocks.bridgeGetOutput }),
}));
import { streamViaCli } from "../provider.js";
function makeProc() {
const proc = new EventEmitter() as any;
proc.stdin = { write: vi.fn(), end: vi.fn() };
proc.stdout = new PassThrough();
proc.stderr = new EventEmitter();
proc.killed = false;
proc.exitCode = null;
proc.kill = vi.fn();
proc.pid = 123;
return proc;
}
describe("streamViaCli", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("spawns droid and writes prompt", async () => {
const proc = makeProc();
mocks.spawnDroid.mockReturnValue(proc);
mocks.parseLine.mockReturnValueOnce({ type: "result", subtype: "success" });
const stream = streamViaCli({ id: "droid-pro", provider: "droid-cli" } as any, { messages: [{ role: "user", content: "hi" }] } as any);
expect(stream).toBeDefined();
await new Promise((r) => setTimeout(r, 0));
proc.stdout.write('{"type":"result","subtype":"success"}\n');
proc.emit("close", 0, null);
await new Promise((r) => setTimeout(r, 0));
expect(mocks.spawnDroid).toHaveBeenCalled();
expect(mocks.writeUserMessage).toHaveBeenCalled();
});
it("forwards stream events to bridge", async () => {
const proc = makeProc();
mocks.spawnDroid.mockReturnValue(proc);
mocks.parseLine
.mockReturnValueOnce({ type: "stream_event", event: { type: "message_start" } })
.mockReturnValueOnce({ type: "result", subtype: "success" });
streamViaCli({ id: "droid-pro", provider: "droid-cli" } as any, { messages: [{ role: "user", content: "hi" }] } as any);
await new Promise((r) => setTimeout(r, 0));
proc.stdout.write('a\n');
proc.stdout.write('b\n');
proc.emit("close", 0, null);
await new Promise((r) => setTimeout(r, 0));
expect(mocks.bridgeHandleEvent).toHaveBeenCalledWith({ type: "message_start" });
});
it("includes mcp config path in spawn args options", async () => {
const proc = makeProc();
mocks.spawnDroid.mockReturnValue(proc);
mocks.parseLine.mockReturnValueOnce({ type: "result", subtype: "success" });
streamViaCli(
{ id: "droid-pro", provider: "droid-cli" } as any,
{ messages: [{ role: "user", content: "hi" }] } as any,
{ mcpConfigPath: "/tmp/mcp.json" } as any,
);
await new Promise((r) => setTimeout(r, 0));
expect(mocks.spawnDroid).toHaveBeenCalledWith(
"droid-pro",
expect.anything(),
expect.objectContaining({ mcpConfigPath: "/tmp/mcp.json" }),
);
});
});

View File

@@ -1,11 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
vi.mock("../provider.js", () => ({
streamViaCli: vi.fn(),
}));
import { streamViaCli } from "../provider.js";
import { DroidRuntimeAdapter } from "../runtime-adapter.js";
describe("DroidRuntimeAdapter", () => {
it("creates session and describes model", async () => {
it("createSession uses configured model and callbacks", async () => {
const onText = vi.fn();
const adapter = new DroidRuntimeAdapter({ droidModel: "droid-pro" });
const result = await adapter.createSession({ cwd: process.cwd(), systemPrompt: "sys", onText: vi.fn() });
expect(result.session).toBeDefined();
expect(adapter.describeModel(result.session)).toContain("droid");
const result = await adapter.createSession({ cwd: process.cwd(), systemPrompt: "sys", onText });
expect(result.session.model).toBe("droid-pro");
expect(result.session.callbacks.onText).toBe(onText);
expect(adapter.describeModel(result.session)).toBe("droid/droid-pro");
});
it("promptWithFallback forwards text/thinking deltas", async () => {
const stream = new EventEmitter();
const mockStreamViaCli = vi.mocked(streamViaCli);
mockStreamViaCli.mockReturnValue(stream as any);
const onText = vi.fn();
const onThinking = vi.fn();
const adapter = new DroidRuntimeAdapter({ droidModel: "droid-pro" });
const { session } = await adapter.createSession({ cwd: process.cwd(), systemPrompt: "sys", onText, onThinking });
const pending = adapter.promptWithFallback(session, "hello");
stream.emit("text_delta", { text: "a" });
stream.emit("thinking_delta", { text: "b" });
stream.emit("done");
await pending;
expect(onText).toHaveBeenCalledWith("a");
expect(onThinking).toHaveBeenCalledWith("b");
expect(mockStreamViaCli).toHaveBeenCalledWith(
expect.objectContaining({ id: "droid-pro", provider: "droid-cli" }),
expect.objectContaining({ systemPrompt: "sys" }),
expect.objectContaining({ sessionId: "" }),
);
});
it("promptWithFallback resolves on stream error", async () => {
const stream = new EventEmitter();
vi.mocked(streamViaCli).mockReturnValue(stream as any);
const adapter = new DroidRuntimeAdapter();
const { session } = await adapter.createSession({ cwd: process.cwd(), systemPrompt: "sys", onText: vi.fn() });
const pending = adapter.promptWithFallback(session, "hello");
stream.emit("error", new Error("boom"));
await expect(pending).resolves.toBeUndefined();
});
});

View File

@@ -32,10 +32,36 @@ const plugin: FusionPlugin = definePlugin({
},
},
uiSlots: [
{ slotId: "settings-provider-card", label: "Droid CLI Provider", componentPath: "./components/settings-provider-card.js" },
{ slotId: "onboarding-provider-card", label: "Droid CLI Provider", componentPath: "./components/onboarding-provider-card.js" },
{ slotId: "onboarding-setup-help", label: "Droid CLI Setup Help", componentPath: "./components/onboarding-setup-help.js" },
{ slotId: "post-onboarding-recommendation", label: "Droid CLI Recommendation", componentPath: "./components/post-onboarding-recommendation.js" }
{
slotId: "settings-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/settings-provider-card.js",
order: 10,
},
{
slotId: "settings-integration-card",
label: "Droid CLI Integration",
componentPath: "./components/settings-integration-card.js",
order: 20,
},
{
slotId: "onboarding-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/onboarding-provider-card.js",
order: 10,
},
{
slotId: "onboarding-setup-help",
label: "Droid CLI Setup Help",
componentPath: "./components/onboarding-setup-help.js",
order: 20,
},
{
slotId: "post-onboarding-recommendation",
label: "Droid CLI Recommendation",
componentPath: "./components/post-onboarding-recommendation.js",
order: 10,
},
],
runtime: {
metadata: droidRuntimeMetadata,