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:
@@ -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