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