Files
fusion/packages/droid-cli/src/__tests__/index.test.ts
gsxdsm 5191e1f22d FN-6911: defer Droid CLI startup probes
Defer Droid CLI validation and discovery so startup no longer spawns or hangs on droid.

- Register the Droid provider without boot-time validation or model discovery side effects.
- Trigger CLI validation only when a Droid stream starts and expose explicit model discovery.
- Add regression coverage for non-interactive droid process spawning and startup probe behavior.
- Add a patch changeset for the published Fusion CLI package.

Files changed:
 .changeset/fn-6911-droid-cli-no-boot-spawn.md      |  5 ++
 .../commands/__tests__/droid-cli-extension.test.ts | 15 +++-
 packages/droid-cli/index.ts                        | 33 ++++----
 packages/droid-cli/src/__tests__/index.test.ts     | 90 +++++++++-------------
 .../src/__tests__/discover-models.test.ts          |  7 +-
 .../src/__tests__/probe.test.ts                    |  8 ++
 .../src/__tests__/process-manager.test.ts          | 74 ++++++++++++++++++
 .../src/__tests__/startup-probes.test.ts           |  5 +-
 8 files changed, 160 insertions(+), 77 deletions(-)

Fusion-Task-Id: FN-6911

Fusion-Task-Lineage: ef4bc3df-bfe8-416a-b701-64f752faea30
2026-06-21 23:44:26 -07:00

255 lines
8.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type ToolDescriptor = {
name: string;
description: string;
parameters: Record<string, unknown>;
};
const runtimeMocks = vi.hoisted(() => {
return {
streamViaCli: vi.fn(() => ({ push: vi.fn(), end: vi.fn() })),
discoverDroidModels: vi.fn(async () => ["droid-pro", "droid-max"]),
validateCliPresenceAsync: vi.fn(async () => ({ ok: true })),
validateCliAuthAsync: vi.fn(async () => undefined),
killAllProcesses: vi.fn(),
getCustomToolDefs: vi.fn(() => [
{ name: "fn_read", description: "Read", input_schema: { type: "object" } },
]),
toolsFromContext: vi.fn((tools?: readonly ToolDescriptor[]) =>
Array.isArray(tools)
? tools.map((tool) => ({
name: tool.name,
description: tool.description,
input_schema: tool.parameters,
}))
: [],
),
writeMcpConfig: vi.fn((_: unknown, hash: string) => `/tmp/droid-mcp-${hash}.json`),
};
});
vi.mock("@fusion-plugin-examples/droid-runtime", () => runtimeMocks);
const flushAsyncRegistration = async () => {
await Promise.resolve();
await Promise.resolve();
};
describe("droid-cli extension entrypoint", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
runtimeMocks.discoverDroidModels.mockResolvedValue(["droid-pro", "droid-max"]);
runtimeMocks.validateCliPresenceAsync.mockResolvedValue({ ok: true });
runtimeMocks.validateCliAuthAsync.mockResolvedValue(undefined);
runtimeMocks.toolsFromContext.mockImplementation((tools?: readonly ToolDescriptor[]) =>
Array.isArray(tools)
? tools.map((tool) => ({
name: tool.name,
description: tool.description,
input_schema: tool.parameters,
}))
: [],
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("registers provider droid-cli synchronously without starting droid probes or discovery", async () => {
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);
await flushAsyncRegistration();
expect(result).toBeUndefined();
expect(runtimeMocks.validateCliPresenceAsync).not.toHaveBeenCalled();
expect(runtimeMocks.validateCliAuthAsync).not.toHaveBeenCalled();
expect(runtimeMocks.discoverDroidModels).not.toHaveBeenCalled();
expect(registerProvider).toHaveBeenCalledTimes(1);
const [providerId, config] = registerProvider.mock.calls[0] as [string, {
baseUrl: string;
api: string;
apiKey: string;
models: unknown[];
streamSimple: Function;
}];
expect(providerId).toBe("droid-cli");
expect(config.baseUrl).toBe("droid-cli");
expect(config.api).toBe("droid-cli");
expect(config.apiKey).toBe("unused");
expect(config.models).toEqual([]);
expect(typeof config.streamSimple).toBe("function");
});
it("runs validation once when a droid stream is actually used", async () => {
const registerProvider = vi.fn();
const mockPi = {
registerProvider,
on: vi.fn(),
getAllTools: vi.fn(() => []),
setActiveTools: vi.fn(),
};
const mod = await import("../../index");
mod.default(mockPi as never);
const config = registerProvider.mock.calls[0]?.[1] as {
streamSimple: (model: unknown, context: unknown, options?: Record<string, unknown>) => unknown;
};
config.streamSimple({ id: "droid-pro" }, { messages: [] }, {});
config.streamSimple({ id: "droid-pro" }, { messages: [] }, {});
await flushAsyncRegistration();
expect(runtimeMocks.validateCliPresenceAsync).toHaveBeenCalledTimes(1);
expect(runtimeMocks.validateCliAuthAsync).toHaveBeenCalledTimes(1);
expect(runtimeMocks.discoverDroidModels).not.toHaveBeenCalled();
});
it("discovers provider models only when explicitly requested", async () => {
const mod = await import("../../index");
await expect(mod.discoverDroidProviderModels()).resolves.toEqual([
expect.objectContaining({ id: "droid-pro", name: "droid-pro", contextWindow: 200_000, maxTokens: 8_192 }),
expect.objectContaining({ id: "droid-max", name: "droid-max", contextWindow: 200_000, maxTokens: 8_192 }),
]);
expect(runtimeMocks.discoverDroidModels).toHaveBeenCalledTimes(1);
});
it("activates all registered tools on session_start", async () => {
const sessionStartHandlers: Array<() => Promise<void>> = [];
const mockPi = {
registerProvider: vi.fn(),
on: vi.fn((event: string, handler: () => Promise<void>) => {
if (event === "session_start") sessionStartHandlers.push(handler);
}),
getAllTools: vi.fn(() => [{ name: "find" }, { name: "grep" }]),
setActiveTools: vi.fn(),
};
const mod = await import("../../index");
mod.default(mockPi as never);
await flushAsyncRegistration();
expect(sessionStartHandlers).toHaveLength(1);
await sessionStartHandlers[0]();
expect(mockPi.setActiveTools).toHaveBeenCalledWith(["find", "grep"]);
});
it("warns but still registers provider when cli presence check fails", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
runtimeMocks.validateCliPresenceAsync.mockResolvedValue({
ok: false,
error: { message: "droid CLI missing" },
} as any);
const mockPi = {
registerProvider: vi.fn(),
on: vi.fn(),
getAllTools: vi.fn(() => []),
setActiveTools: vi.fn(),
};
const mod = await import("../../index");
mod.default(mockPi as never);
const config = mockPi.registerProvider.mock.calls[0]?.[1] as {
streamSimple: (model: unknown, context: unknown, options?: Record<string, unknown>) => unknown;
};
config.streamSimple({ id: "droid-pro" }, { messages: [] }, {});
await flushAsyncRegistration();
expect(warnSpy).toHaveBeenCalledWith("[droid-cli] droid CLI missing");
expect(runtimeMocks.validateCliAuthAsync).not.toHaveBeenCalled();
expect(mockPi.registerProvider).toHaveBeenCalledWith("droid-cli", expect.objectContaining({ models: [] }));
});
it("falls back to empty models when discovery throws", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
runtimeMocks.discoverDroidModels.mockRejectedValue(new Error("boom"));
const mod = await import("../../index");
await expect(mod.discoverDroidProviderModels()).resolves.toEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
"[droid-cli] model auto-discovery failed; registering provider with empty model list",
expect.any(Error),
);
});
it("wires context tools to mcp config and passes mcpConfigPath into streamViaCli", async () => {
const registerProvider = vi.fn();
const mockPi = {
registerProvider,
on: vi.fn(),
getAllTools: vi.fn(() => [{ name: "find" }]),
setActiveTools: vi.fn(),
};
const mod = await import("../../index");
mod.default(mockPi as never);
await flushAsyncRegistration();
const config = registerProvider.mock.calls[0]?.[1] as {
streamSimple: (model: unknown, context: unknown, options: Record<string, unknown>) => unknown;
};
const context = {
tools: [
{
name: "fn_web_fetch",
description: "Fetch URL",
parameters: { type: "object", properties: { url: { type: "string" } } },
},
],
};
config.streamSimple({ id: "droid-pro" }, context, { temperature: 0.2 });
expect(runtimeMocks.toolsFromContext).toHaveBeenCalledWith(context.tools);
expect(runtimeMocks.writeMcpConfig).toHaveBeenCalledTimes(1);
expect(runtimeMocks.streamViaCli).toHaveBeenCalledWith(
{ id: "droid-pro" },
context,
expect.objectContaining({ temperature: 0.2, mcpConfigPath: expect.stringContaining("/tmp/droid-mcp-") }),
);
});
it("falls back to getCustomToolDefs when context tools are missing", async () => {
runtimeMocks.toolsFromContext.mockReturnValue([]);
const registerProvider = vi.fn();
const mockPi = {
registerProvider,
on: vi.fn(),
getAllTools: vi.fn(() => [{ name: "ls" }]),
setActiveTools: vi.fn(),
};
const mod = await import("../../index");
mod.default(mockPi as never);
await flushAsyncRegistration();
const config = registerProvider.mock.calls[0]?.[1] as {
streamSimple: (model: unknown, context: unknown, options?: Record<string, unknown>) => unknown;
};
config.streamSimple({ id: "droid-pro" }, { messages: [] }, {});
expect(runtimeMocks.getCustomToolDefs).toHaveBeenCalledWith(mockPi);
expect(runtimeMocks.writeMcpConfig).toHaveBeenCalledTimes(1);
});
});