FN-6878: register Droid provider before probes

Register Droid provider startup without waiting on local binary probes.

- Register the Droid CLI provider synchronously with an empty model list, then refresh discovered models asynchronously.
- Harden Droid runtime probes to convert spawn errors and timeouts into unavailable sentinel results.
- Add coverage for non-blocking startup, failed probes, unavailable binaries, and process cleanup behavior.
- Add a patch changeset for the published Fusion CLI package.

Files changed:
 .changeset/fn-6878-droid-boot.md                   |  5 ++
 packages/droid-cli/index.ts                        | 73 +++++++++++-------
 packages/droid-cli/src/__tests__/index.test.ts     | 52 ++++++++++++-
 .../src/__tests__/process-manager.test.ts          | 58 +++++++--------
 .../src/__tests__/discover-models.test.ts          |  8 ++
 .../src/__tests__/probe.test.ts                    | 43 ++++++++++-
 .../src/__tests__/startup-probes.test.ts           | 86 ++++++++++++++++++++++
 plugins/fusion-plugin-droid-runtime/src/probe.ts   | 32 +++++---
 8 files changed, 283 insertions(+), 74 deletions(-)

Fusion-Task-Id: FN-6878

Fusion-Task-Lineage: 4e44a6e8-0eea-4867-a172-3bdc0c6368e1
This commit is contained in:
gsxdsm
2026-06-21 16:40:32 -07:00
parent 09acfbb707
commit 1f23a2e622
8 changed files with 282 additions and 73 deletions

View File

@@ -40,15 +40,7 @@ async function getDiscoveredModels() {
try {
const ids = Array.from(new Set(await discoverDroidModels()));
if (ids.length === 0) return [];
return ids.map((id) => ({
id,
name: id,
reasoning: true,
input: ["text", "image"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
}));
return toProviderModels(ids);
} catch (error) {
console.warn("[droid-cli] model auto-discovery failed; registering provider with empty model list", error);
return [];
@@ -60,6 +52,18 @@ async function getDiscoveredModels() {
let cachedMcpConfig: { hash: string; configPath: string } | undefined;
function toProviderModels(ids: string[]): DiscoveredModel[] {
return ids.map((id) => ({
id,
name: id,
reasoning: true,
input: ["text", "image"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
}));
}
function ensureMcpConfig(
pi: ExtensionAPI,
contextTools?: ReadonlyArray<{
@@ -93,7 +97,31 @@ function ensureMcpConfig(
}
}
function registerDroidProvider(pi: ExtensionAPI, models: DiscoveredModel[]) {
pi.registerProvider(PROVIDER_ID, {
baseUrl: "droid-cli",
apiKey: "unused",
api: "droid-cli",
models,
streamSimple: ((model, context, options) => {
const configPath = ensureMcpConfig(
pi,
(context as { tools?: ReadonlyArray<{ name: string; description: string; parameters: Record<string, unknown> }> }).tools,
);
return streamViaCli(
model,
context as never,
{ ...(options ?? {}), mcpConfigPath: configPath } as never,
) as unknown as ReturnType<StreamSimpleHandler>;
}) as StreamSimpleHandler,
});
}
export default function (pi: ExtensionAPI) {
/*
FNXC:CliRuntime 2026-06-21-12:00:
Engine and dashboard startup must not wait for local Droid CLI probes. Register the provider synchronously with an empty model list, then launch presence/auth/model discovery as fire-and-forget bounded probes so a missing or wedged `droid` binary cannot stall extension loading.
*/
void runCliValidationOnce();
pi.on("session_start", async () => {
@@ -103,28 +131,19 @@ export default function (pi: ExtensionAPI) {
}
});
try {
registerDroidProvider(pi, []);
} catch (err) {
console.error("[droid-cli] Failed to register provider:", err);
}
void (async () => {
const models = await getDiscoveredModels();
if (models.length === 0) return;
try {
pi.registerProvider(PROVIDER_ID, {
baseUrl: "droid-cli",
apiKey: "unused",
api: "droid-cli",
models,
streamSimple: ((model, context, options) => {
const configPath = ensureMcpConfig(
pi,
(context as { tools?: ReadonlyArray<{ name: string; description: string; parameters: Record<string, unknown> }> }).tools,
);
return streamViaCli(
model,
context as never,
{ ...(options ?? {}), mcpConfigPath: configPath } as never,
) as unknown as ReturnType<StreamSimpleHandler>;
}) as StreamSimpleHandler,
});
registerDroidProvider(pi, models);
} catch (err) {
console.error("[droid-cli] Failed to register provider:", err);
console.error("[droid-cli] Failed to refresh discovered provider models:", err);
}
})();
}

View File

@@ -75,8 +75,14 @@ describe("droid-cli extension entrypoint", () => {
expect(runtimeMocks.validateCliAuthAsync).toHaveBeenCalledTimes(1);
expect(runtimeMocks.discoverDroidModels).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledTimes(1);
const [providerId, config] = registerProvider.mock.calls[0] as [string, {
expect(registerProvider).toHaveBeenCalledTimes(2);
const [initialProviderId, initialConfig] = registerProvider.mock.calls[0] as [string, {
models: unknown[];
}];
expect(initialProviderId).toBe("droid-cli");
expect(initialConfig.models).toEqual([]);
const [providerId, config] = registerProvider.mock.calls.at(-1) as [string, {
baseUrl: string;
api: string;
apiKey: string;
@@ -95,6 +101,45 @@ describe("droid-cli extension entrypoint", () => {
expect(typeof config.streamSimple).toBe("function");
});
it("registers the provider synchronously without awaiting droid probes", async () => {
let resolvePresence!: (value: { ok: true }) => void;
let resolveDiscovery!: (value: string[]) => void;
runtimeMocks.validateCliPresenceAsync.mockImplementation(
() => new Promise((resolve) => { resolvePresence = resolve; }),
);
runtimeMocks.discoverDroidModels.mockImplementation(
() => new Promise((resolve) => { resolveDiscovery = resolve; }),
);
const registerProvider = vi.fn();
const mockPi = {
registerProvider,
on: vi.fn(),
getAllTools: vi.fn(() => []),
setActiveTools: vi.fn(),
};
const mod = await import("../../index");
const result = mod.default(mockPi as never);
expect(result).toBeUndefined();
expect(runtimeMocks.validateCliPresenceAsync).toHaveBeenCalledTimes(1);
expect(runtimeMocks.discoverDroidModels).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledWith("droid-cli", expect.objectContaining({ models: [] }));
expect(runtimeMocks.validateCliAuthAsync).not.toHaveBeenCalled();
resolvePresence({ ok: true });
resolveDiscovery(["droid-pro"]);
await flushAsyncRegistration();
expect(runtimeMocks.validateCliAuthAsync).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledTimes(2);
expect(registerProvider.mock.calls[1]?.[1]).toMatchObject({
models: [expect.objectContaining({ id: "droid-pro" })],
});
});
it("activates all registered tools on session_start", async () => {
const sessionStartHandlers: Array<() => Promise<void>> = [];
const mockPi = {
@@ -136,7 +181,7 @@ describe("droid-cli extension entrypoint", () => {
expect(warnSpy).toHaveBeenCalledWith("[droid-cli] droid CLI missing");
expect(runtimeMocks.validateCliAuthAsync).not.toHaveBeenCalled();
expect(mockPi.registerProvider).toHaveBeenCalledTimes(1);
expect(mockPi.registerProvider).toHaveBeenCalledWith("droid-cli", expect.objectContaining({ models: [] }));
});
it("falls back to empty models when discovery throws", async () => {
@@ -157,6 +202,7 @@ describe("droid-cli extension entrypoint", () => {
const config = registerProvider.mock.calls[0]?.[1] as { models: unknown[] };
expect(config.models).toEqual([]);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
"[droid-cli] model auto-discovery failed; registering provider with empty model list",
expect.any(Error),

View File

@@ -748,52 +748,44 @@ describe("discoverDroidModels", () => {
vi.clearAllMocks();
});
it("parses model ids from JSON output", async () => {
it("parses model ids from droid exec --help output", async () => {
(spawn as any).mockImplementationOnce(() => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setTimeout(() => {
proc.stdout.emit("data", Buffer.from('[{"id":"droid-pro"},{"name":"droid-max"}]'));
proc.stdout.emit("data", Buffer.from(`Usage: droid exec [options] [prompt]
Available Models:
droid-pro Droid Pro
droid-max Droid Max
Model details:
- Droid Pro: prose, not a model id
`));
proc.emit("exit", 0);
}, 0);
return proc;
});
await expect(discoverDroidModels()).resolves.toEqual(["droid-pro", "droid-max"]);
expect(spawn).toHaveBeenCalledWith("droid", ["exec", "--help"], expect.anything());
});
it("falls back across attempts and parses newline output", async () => {
(spawn as any)
.mockImplementationOnce(() => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setTimeout(() => proc.emit("exit", 1), 0);
return proc;
})
.mockImplementationOnce(() => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setTimeout(() => proc.emit("exit", 1), 0);
return proc;
})
.mockImplementationOnce(() => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setTimeout(() => {
proc.stdout.emit("data", Buffer.from("droid-lite\ndroid-lite\ndroid-pro\n"));
proc.emit("exit", 0);
}, 0);
return proc;
});
it("returns [] when droid exec --help exits without a model section", async () => {
(spawn as any).mockImplementationOnce(() => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setTimeout(() => {
proc.stdout.emit("data", Buffer.from("Usage: droid exec\n\nOptions:\n --help\n"));
proc.emit("exit", 0);
}, 0);
return proc;
});
await expect(discoverDroidModels()).resolves.toEqual(["droid-lite", "droid-pro"]);
await expect(discoverDroidModels()).resolves.toEqual([]);
});
});