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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user