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

@@ -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([]);
});
});