@runfusion/fusion 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +13 -0
  2. package/dist/bin.js +1707 -610
  3. package/dist/client/assets/AgentDetailView-CBFUveyO.js +18 -0
  4. package/dist/client/assets/AgentsView-DPezXQ-U.js +522 -0
  5. package/dist/client/assets/{AgentsView-Bkk-uBij.css → AgentsView-V5GhlBYu.css} +1 -1
  6. package/dist/client/assets/ChatView-5N4-EuhD.js +1 -0
  7. package/dist/client/assets/{DevServerView-DQrVLbK5.js → DevServerView-Daft4YFc.js} +1 -1
  8. package/dist/client/assets/{DirectoryPicker-DVmy6sLM.js → DirectoryPicker-rew1y6qO.js} +1 -1
  9. package/dist/client/assets/{DocumentsView-DHEv-Q2a.js → DocumentsView-i72qJzwd.js} +1 -1
  10. package/dist/client/assets/{InsightsView-ByyY7GX7.js → InsightsView-BL5eZJ0a.js} +3 -3
  11. package/dist/client/assets/{MemoryView-Udiu0u8R.js → MemoryView-pl8Cdg_p.js} +2 -2
  12. package/dist/client/assets/{NodesView-CupS-GGc.js → NodesView-D6eJ15zc.js} +4 -4
  13. package/dist/client/assets/PiExtensionsManager-ExInwXWP.js +11 -0
  14. package/dist/client/assets/PluginManager-CYhtxHun.js +1 -0
  15. package/dist/client/assets/{ResearchView-BG9Feaeb.js → ResearchView-B_QPUEjB.js} +1 -1
  16. package/dist/client/assets/{RoadmapsView-BTJtmBnF.js → RoadmapsView-DBNLaEsK.js} +2 -2
  17. package/dist/client/assets/SettingsModal-1ET586M3.js +31 -0
  18. package/dist/client/assets/{SettingsModal-eNCZiHa6.js → SettingsModal-CL_gWmOj.js} +1 -1
  19. package/dist/client/assets/SettingsModal-D_AFkDJa.css +1 -0
  20. package/dist/client/assets/{SetupWizardModal-yf79TN1L.js → SetupWizardModal-CLkY9HFL.js} +1 -1
  21. package/dist/client/assets/{SkillMultiselect-DOj5vX4U.js → SkillMultiselect-B0qi32SQ.js} +1 -1
  22. package/dist/client/assets/{SkillsView-CgnCnikX.js → SkillsView-umVjRq6o.js} +1 -1
  23. package/dist/client/assets/TodoView-CFifSvrD.js +6 -0
  24. package/dist/client/assets/TodoView-SeO9o7km.css +1 -0
  25. package/dist/client/assets/{folder-open-D11gjHGK.js → folder-open-nYPrL1W3.js} +1 -1
  26. package/dist/client/assets/index-Bc8nfKeH.js +661 -0
  27. package/dist/client/assets/index-C1prPuSl.css +1 -0
  28. package/dist/client/assets/{list-checks-CBzPc3GA.js → list-checks-sK8xJeH_.js} +1 -1
  29. package/dist/client/assets/{star-BWcRk8nt.js → star-BRtXbYkB.js} +1 -1
  30. package/dist/client/assets/{upload-91TM4ljC.js → upload-BP60eBwN.js} +1 -1
  31. package/dist/client/assets/{users-BAsI___L.js → users-qSGAX2Pf.js} +1 -1
  32. package/dist/client/index.html +2 -2
  33. package/dist/client/sw.js +6 -0
  34. package/dist/client/version.json +1 -1
  35. package/dist/droid-cli/index.ts +127 -0
  36. package/dist/droid-cli/package.json +37 -0
  37. package/dist/droid-cli/src/__tests__/control-handler.test.ts +164 -0
  38. package/dist/droid-cli/src/__tests__/event-bridge.test.ts +1318 -0
  39. package/dist/droid-cli/src/__tests__/mcp-config.test.ts +310 -0
  40. package/dist/droid-cli/src/__tests__/process-manager.test.ts +818 -0
  41. package/dist/droid-cli/src/__tests__/prompt-builder.test.ts +1206 -0
  42. package/dist/droid-cli/src/__tests__/provider.test.ts +1894 -0
  43. package/dist/droid-cli/src/__tests__/setup-test-isolation.test.ts +32 -0
  44. package/dist/droid-cli/src/__tests__/setup-test-isolation.ts +14 -0
  45. package/dist/droid-cli/src/__tests__/stream-parser.test.ts +188 -0
  46. package/dist/droid-cli/src/__tests__/thinking-config.test.ts +141 -0
  47. package/dist/droid-cli/src/__tests__/tool-mapping.test.ts +253 -0
  48. package/dist/droid-cli/src/control-handler.ts +82 -0
  49. package/dist/droid-cli/src/event-bridge.ts +397 -0
  50. package/dist/droid-cli/src/mcp-config.ts +144 -0
  51. package/dist/droid-cli/src/mcp-schema-server.cjs +49 -0
  52. package/dist/droid-cli/src/process-manager.ts +358 -0
  53. package/dist/droid-cli/src/prompt-builder.ts +629 -0
  54. package/dist/droid-cli/src/provider.ts +447 -0
  55. package/dist/droid-cli/src/stream-parser.ts +37 -0
  56. package/dist/droid-cli/src/thinking-config.ts +83 -0
  57. package/dist/droid-cli/src/tool-mapping.ts +147 -0
  58. package/dist/droid-cli/src/types.ts +87 -0
  59. package/dist/extension.js +542 -141
  60. package/dist/pi-claude-cli/package.json +1 -1
  61. package/dist/pi-claude-cli/src/__tests__/prompt-builder.test.ts +36 -0
  62. package/dist/pi-claude-cli/src/prompt-builder.ts +19 -28
  63. package/package.json +2 -1
  64. package/dist/client/assets/AgentDetailView-B20ApPe1.js +0 -18
  65. package/dist/client/assets/AgentsView-ChN1tgQ0.js +0 -522
  66. package/dist/client/assets/ChatView-oPMFwmoc.js +0 -1
  67. package/dist/client/assets/PiExtensionsManager-DXs2xI8K.js +0 -11
  68. package/dist/client/assets/PluginManager-BCpiZf4_.js +0 -1
  69. package/dist/client/assets/SettingsModal-9HS8MnmW.css +0 -1
  70. package/dist/client/assets/SettingsModal-DZ_LaEhd.js +0 -31
  71. package/dist/client/assets/TodoView-67BMyICY.js +0 -6
  72. package/dist/client/assets/TodoView-C1g65hJo.css +0 -1
  73. package/dist/client/assets/index-BLn1R7Ob.css +0 -1
  74. package/dist/client/assets/index-CLAHcGnI.js +0 -656
@@ -0,0 +1,818 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { ChildProcess } from "node:child_process";
3
+
4
+ // Mock child_process.spawn before importing process-manager
5
+ vi.mock("node:child_process", () => ({
6
+ spawn: vi.fn(() => {
7
+ const EventEmitter = require("node:events");
8
+ const proc = new EventEmitter();
9
+ proc.stdin = { write: vi.fn(), end: vi.fn() };
10
+ proc.stdout = new EventEmitter();
11
+ proc.stderr = new EventEmitter();
12
+ proc.killed = false;
13
+ proc.kill = vi.fn(() => {
14
+ proc.killed = true;
15
+ });
16
+ proc.pid = 12345;
17
+ return proc;
18
+ }),
19
+ execSync: vi.fn(),
20
+ }));
21
+
22
+ const mocks = vi.hoisted(() => ({
23
+ writeFileSync: vi.fn(),
24
+ unlinkSync: vi.fn(),
25
+ existsSync: vi.fn(),
26
+ readFileSync: vi.fn(),
27
+ tmpdir: vi.fn(() => "/mock-tmp"),
28
+ }));
29
+
30
+ vi.mock("node:fs", () => ({
31
+ writeFileSync: mocks.writeFileSync,
32
+ unlinkSync: mocks.unlinkSync,
33
+ existsSync: mocks.existsSync,
34
+ readFileSync: mocks.readFileSync,
35
+ }));
36
+
37
+ vi.mock("node:os", () => ({
38
+ tmpdir: mocks.tmpdir,
39
+ }));
40
+
41
+ import { spawn, execSync } from "node:child_process";
42
+ import {
43
+ spawnDroid,
44
+ buildDroidSpawnArgs,
45
+ writeUserMessage,
46
+ cleanupProcess,
47
+ captureStderr,
48
+ validateCliPresence,
49
+ validateCliAuth,
50
+ validateCliPresenceAsync,
51
+ validateCliAuthAsync,
52
+ forceKillProcess,
53
+ registerProcess,
54
+ killAllProcesses,
55
+ cleanupSystemPromptFile,
56
+ discoverDroidModels,
57
+ } from "../process-manager";
58
+
59
+ describe("buildDroidSpawnArgs", () => {
60
+ beforeEach(() => {
61
+ vi.clearAllMocks();
62
+ mocks.writeFileSync.mockReset();
63
+ mocks.tmpdir.mockReset();
64
+ mocks.tmpdir.mockReturnValue("/mock-tmp");
65
+ });
66
+
67
+ it("builds args including model and optional session/mcp flags", () => {
68
+ const args = buildDroidSpawnArgs("claude-sonnet-4-6", undefined, {
69
+ resumeSessionId: "sess-1",
70
+ effort: "high",
71
+ mcpConfigPath: "/tmp/mcp.json",
72
+ });
73
+
74
+ expect(args).toContain("--model");
75
+ expect(args).toContain("claude-sonnet-4-6");
76
+ expect(args).toContain("--resume");
77
+ expect(args).toContain("sess-1");
78
+ expect(args).toContain("--effort");
79
+ expect(args).toContain("high");
80
+ expect(args).toContain("--mcp-config");
81
+ expect(args).toContain("/tmp/mcp.json");
82
+ });
83
+ });
84
+
85
+ describe("spawnDroid", () => {
86
+ beforeEach(() => {
87
+ vi.clearAllMocks();
88
+ mocks.writeFileSync.mockReset();
89
+ mocks.existsSync.mockReset();
90
+ mocks.readFileSync.mockReset();
91
+ mocks.tmpdir.mockReset();
92
+ mocks.tmpdir.mockReturnValue("/mock-tmp");
93
+ });
94
+
95
+ it("spawns claude with all required CLI flags", () => {
96
+ spawnDroid("claude-sonnet-4-5-20250929");
97
+
98
+ expect(spawn).toHaveBeenCalledTimes(1);
99
+ const [cmd, args] = (spawn as any).mock.calls[0];
100
+
101
+ expect(cmd).toBe("droid");
102
+ expect(args).toContain("-p");
103
+ expect(args).toContain("--input-format");
104
+ expect(args).toContain("stream-json");
105
+ expect(args).toContain("--output-format");
106
+ expect(args).toContain("--verbose");
107
+ expect(args).toContain("--include-partial-messages");
108
+ expect(args).not.toContain("--no-session-persistence");
109
+ expect(args).toContain("--model");
110
+ expect(args).toContain("claude-sonnet-4-5-20250929");
111
+ expect(args).not.toContain("--permission-prompt-tool");
112
+ expect(args).not.toContain("stdio");
113
+ });
114
+
115
+ it("passes stream-json for both input-format and output-format", () => {
116
+ spawnDroid("claude-sonnet-4-5-20250929");
117
+ const args = (spawn as any).mock.calls[0][1] as string[];
118
+
119
+ const inputFormatIdx = args.indexOf("--input-format");
120
+ expect(args[inputFormatIdx + 1]).toBe("stream-json");
121
+
122
+ const outputFormatIdx = args.indexOf("--output-format");
123
+ expect(args[outputFormatIdx + 1]).toBe("stream-json");
124
+ });
125
+
126
+ it("sets stdio to pipe for stdin, stdout, and stderr", () => {
127
+ spawnDroid("claude-sonnet-4-5-20250929");
128
+ const options = (spawn as any).mock.calls[0][2];
129
+ expect(options.stdio).toEqual(["pipe", "pipe", "pipe"]);
130
+ });
131
+
132
+ it("passes cwd from options when provided", () => {
133
+ spawnDroid("claude-sonnet-4-5-20250929", undefined, {
134
+ cwd: "/custom/path",
135
+ });
136
+ const options = (spawn as any).mock.calls[0][2];
137
+ expect(options.cwd).toBe("/custom/path");
138
+ });
139
+
140
+ it("writes system prompt to temp file and passes path via --append-system-prompt", () => {
141
+ spawnDroid("claude-sonnet-4-5-20250929", "You are a helpful assistant.");
142
+ const args = (spawn as any).mock.calls[0][1] as string[];
143
+ const expectedTmpFile = `/mock-tmp/droid-cli-sysprompt-${process.pid}.txt`;
144
+
145
+ expect(mocks.writeFileSync).toHaveBeenCalledWith(
146
+ expectedTmpFile,
147
+ "You are a helpful assistant.",
148
+ "utf-8",
149
+ );
150
+ expect(args).toContain("--append-system-prompt");
151
+ const idx = args.indexOf("--append-system-prompt");
152
+ expect(args[idx + 1]).toContain("droid-cli-sysprompt-");
153
+ expect(args[idx + 1]).toBe(expectedTmpFile);
154
+ });
155
+
156
+ it("temp file contains the system prompt text", () => {
157
+ spawnDroid("claude-sonnet-4-5-20250929", "You are a helpful assistant.");
158
+
159
+ expect(mocks.writeFileSync).toHaveBeenCalledWith(
160
+ `/mock-tmp/droid-cli-sysprompt-${process.pid}.txt`,
161
+ "You are a helpful assistant.",
162
+ "utf-8",
163
+ );
164
+ });
165
+
166
+ it("does not include --append-system-prompt when no system prompt", () => {
167
+ spawnDroid("claude-sonnet-4-5-20250929");
168
+ const args = (spawn as any).mock.calls[0][1] as string[];
169
+ expect(args).not.toContain("--append-system-prompt");
170
+ });
171
+
172
+ it("returns the spawned ChildProcess", () => {
173
+ const proc = spawnDroid("claude-sonnet-4-5-20250929");
174
+ expect(proc).toBeDefined();
175
+ expect(proc.pid).toBe(12345);
176
+ });
177
+ });
178
+
179
+ describe("effort flag", () => {
180
+ beforeEach(() => {
181
+ vi.clearAllMocks();
182
+ });
183
+
184
+ it("includes --effort and high in args when effort is high", () => {
185
+ spawnDroid("claude-sonnet-4-5-20250929", undefined, { effort: "high" });
186
+ const args = (spawn as any).mock.calls[0][1] as string[];
187
+
188
+ expect(args).toContain("--effort");
189
+ const idx = args.indexOf("--effort");
190
+ expect(args[idx + 1]).toBe("high");
191
+ });
192
+
193
+ it("includes --effort and max in args when effort is max", () => {
194
+ spawnDroid("claude-opus-4-6-20260301", undefined, { effort: "max" });
195
+ const args = (spawn as any).mock.calls[0][1] as string[];
196
+
197
+ expect(args).toContain("--effort");
198
+ const idx = args.indexOf("--effort");
199
+ expect(args[idx + 1]).toBe("max");
200
+ });
201
+
202
+ it("includes --effort and low in args when effort is low", () => {
203
+ spawnDroid("claude-sonnet-4-5-20250929", undefined, { effort: "low" });
204
+ const args = (spawn as any).mock.calls[0][1] as string[];
205
+
206
+ expect(args).toContain("--effort");
207
+ const idx = args.indexOf("--effort");
208
+ expect(args[idx + 1]).toBe("low");
209
+ });
210
+
211
+ it("does NOT include --effort when effort is undefined", () => {
212
+ spawnDroid("claude-sonnet-4-5-20250929", undefined, { cwd: "/some/path" });
213
+ const args = (spawn as any).mock.calls[0][1] as string[];
214
+
215
+ expect(args).not.toContain("--effort");
216
+ });
217
+
218
+ it("does NOT include --effort when options is undefined", () => {
219
+ spawnDroid("claude-sonnet-4-5-20250929");
220
+ const args = (spawn as any).mock.calls[0][1] as string[];
221
+
222
+ expect(args).not.toContain("--effort");
223
+ });
224
+
225
+ it("is backward compatible - existing calls without effort still work", () => {
226
+ spawnDroid("claude-sonnet-4-5-20250929", "system prompt", {
227
+ cwd: "/path",
228
+ });
229
+ const args = (spawn as any).mock.calls[0][1] as string[];
230
+
231
+ expect(args).toContain("--append-system-prompt");
232
+ expect(args).not.toContain("--effort");
233
+ });
234
+ });
235
+
236
+ describe("writeUserMessage", () => {
237
+ it("writes correct NDJSON user message to stdin", () => {
238
+ const mockStdin = { write: vi.fn(), end: vi.fn() };
239
+ const proc = { stdin: mockStdin } as unknown as ChildProcess;
240
+
241
+ writeUserMessage(proc, "Hello Claude");
242
+
243
+ expect(mockStdin.write).toHaveBeenCalledTimes(1);
244
+ const written = mockStdin.write.mock.calls[0][0] as string;
245
+ const parsed = JSON.parse(written.trim());
246
+ expect(parsed.type).toBe("user");
247
+ expect(parsed.message.role).toBe("user");
248
+ expect(parsed.message.content).toBe("Hello Claude");
249
+ });
250
+
251
+ it("appends newline to the JSON", () => {
252
+ const mockStdin = { write: vi.fn(), end: vi.fn() };
253
+ const proc = { stdin: mockStdin } as unknown as ChildProcess;
254
+
255
+ writeUserMessage(proc, "test");
256
+
257
+ const written = mockStdin.write.mock.calls[0][0] as string;
258
+ expect(written.endsWith("\n")).toBe(true);
259
+ });
260
+
261
+ it("calls stdin.end() after writing user message", () => {
262
+ const mockStdin = { write: vi.fn(), end: vi.fn() };
263
+ const proc = { stdin: mockStdin } as unknown as ChildProcess;
264
+
265
+ writeUserMessage(proc, "test");
266
+
267
+ expect(mockStdin.end).toHaveBeenCalledTimes(1);
268
+ });
269
+
270
+ it("sends string content in NDJSON when given string", () => {
271
+ const mockStdin = { write: vi.fn(), end: vi.fn() };
272
+ const proc = { stdin: mockStdin } as unknown as ChildProcess;
273
+
274
+ writeUserMessage(proc, "hello");
275
+
276
+ const written = mockStdin.write.mock.calls[0][0] as string;
277
+ const parsed = JSON.parse(written.trim());
278
+ expect(typeof parsed.message.content).toBe("string");
279
+ expect(parsed.message.content).toBe("hello");
280
+ });
281
+
282
+ it("sends array content in NDJSON when given ContentBlock[]", () => {
283
+ const mockStdin = { write: vi.fn(), end: vi.fn() };
284
+ const proc = { stdin: mockStdin } as unknown as ChildProcess;
285
+
286
+ const blocks = [
287
+ { type: "text", text: "hello" },
288
+ {
289
+ type: "image",
290
+ source: { type: "base64", media_type: "image/png", data: "abc" },
291
+ },
292
+ ];
293
+ writeUserMessage(proc, blocks as any);
294
+
295
+ const written = mockStdin.write.mock.calls[0][0] as string;
296
+ const parsed = JSON.parse(written.trim());
297
+ expect(Array.isArray(parsed.message.content)).toBe(true);
298
+ expect(parsed.message.content).toEqual(blocks);
299
+ });
300
+ });
301
+
302
+ describe("cleanupProcess", () => {
303
+ beforeEach(() => {
304
+ vi.useFakeTimers();
305
+ });
306
+
307
+ afterEach(() => {
308
+ vi.useRealTimers();
309
+ });
310
+
311
+ it("kills the process with SIGKILL after 500ms grace period", () => {
312
+ const mockProc: any = {
313
+ killed: false,
314
+ exitCode: null,
315
+ kill: vi.fn(() => {
316
+ mockProc.killed = true;
317
+ }),
318
+ };
319
+
320
+ cleanupProcess(mockProc as ChildProcess);
321
+
322
+ // Not killed immediately
323
+ expect(mockProc.kill).not.toHaveBeenCalled();
324
+
325
+ // Not killed at 400ms
326
+ vi.advanceTimersByTime(400);
327
+ expect(mockProc.kill).not.toHaveBeenCalled();
328
+
329
+ // Killed after 500ms grace period
330
+ vi.advanceTimersByTime(100);
331
+ expect(mockProc.kill).toHaveBeenCalledWith("SIGKILL");
332
+ });
333
+
334
+ it("does not kill if process is already killed", () => {
335
+ const proc = {
336
+ killed: true,
337
+ exitCode: null,
338
+ kill: vi.fn(),
339
+ } as unknown as ChildProcess;
340
+
341
+ cleanupProcess(proc);
342
+ vi.advanceTimersByTime(500);
343
+
344
+ expect(proc.kill).not.toHaveBeenCalled();
345
+ });
346
+ });
347
+
348
+ describe("captureStderr", () => {
349
+ it("returns a function that accumulates stderr data", () => {
350
+ const EventEmitter = require("node:events");
351
+ const stderr = new EventEmitter();
352
+ const proc = { stderr } as unknown as ChildProcess;
353
+
354
+ const getStderr = captureStderr(proc);
355
+
356
+ stderr.emit("data", Buffer.from("error line 1\n"));
357
+ stderr.emit("data", Buffer.from("error line 2\n"));
358
+
359
+ expect(getStderr()).toBe("error line 1\nerror line 2\n");
360
+ });
361
+
362
+ it("returns empty string when no stderr data", () => {
363
+ const EventEmitter = require("node:events");
364
+ const stderr = new EventEmitter();
365
+ const proc = { stderr } as unknown as ChildProcess;
366
+
367
+ const getStderr = captureStderr(proc);
368
+ expect(getStderr()).toBe("");
369
+ });
370
+ });
371
+
372
+ describe("validateCliPresence", () => {
373
+ it("does not throw when droid --version succeeds", () => {
374
+ (execSync as any).mockReturnValue(Buffer.from("1.0.0"));
375
+ expect(() => validateCliPresence()).not.toThrow();
376
+ });
377
+
378
+ it("throws with install instructions when droid --version fails", () => {
379
+ (execSync as any).mockImplementation(() => {
380
+ throw new Error("command not found");
381
+ });
382
+
383
+ expect(() => validateCliPresence()).toThrow();
384
+ try {
385
+ validateCliPresence();
386
+ } catch (e: any) {
387
+ expect(e.message).toContain("Droid CLI not found");
388
+ expect(e.message).toContain("Install Droid CLI");
389
+ }
390
+ });
391
+ });
392
+
393
+ describe("validateCliAuth", () => {
394
+ it("returns true when droid auth status succeeds", () => {
395
+ (execSync as any).mockReturnValue(Buffer.from("Logged in"));
396
+ expect(validateCliAuth()).toBe(true);
397
+ });
398
+
399
+ it("returns false and warns when droid auth status fails", () => {
400
+ (execSync as any).mockImplementation(() => {
401
+ throw new Error("not authenticated");
402
+ });
403
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
404
+
405
+ expect(validateCliAuth()).toBe(false);
406
+ expect(warnSpy).toHaveBeenCalledWith(
407
+ expect.stringContaining("not authenticated"),
408
+ );
409
+ warnSpy.mockRestore();
410
+ });
411
+ });
412
+
413
+ describe("validateCliPresenceAsync", () => {
414
+ beforeEach(() => {
415
+ vi.clearAllMocks();
416
+ });
417
+
418
+ it("resolves ok=true when droid --version exits 0", async () => {
419
+ const EventEmitter = require("node:events");
420
+ (spawn as any).mockImplementationOnce(() => {
421
+ const proc = new EventEmitter();
422
+ proc.kill = vi.fn();
423
+ setImmediate(() => proc.emit("exit", 0));
424
+ return proc;
425
+ });
426
+
427
+ const result = await validateCliPresenceAsync();
428
+ expect(result).toEqual({ ok: true });
429
+ const args = (spawn as any).mock.calls[0][1] as string[];
430
+ expect(args).toEqual(["--version"]);
431
+ });
432
+
433
+ it("resolves ok=false with install message when spawn errors", async () => {
434
+ const EventEmitter = require("node:events");
435
+ (spawn as any).mockImplementationOnce(() => {
436
+ const proc = new EventEmitter();
437
+ proc.kill = vi.fn();
438
+ setImmediate(() => proc.emit("error", new Error("ENOENT")));
439
+ return proc;
440
+ });
441
+
442
+ const result = await validateCliPresenceAsync();
443
+ expect(result.ok).toBe(false);
444
+ if (!result.ok) {
445
+ expect(result.error.message).toContain("Droid CLI not found");
446
+ expect(result.error.message).toContain("Install Droid CLI");
447
+ }
448
+ });
449
+
450
+ it("resolves ok=false when droid --version exits non-zero", async () => {
451
+ const EventEmitter = require("node:events");
452
+ (spawn as any).mockImplementationOnce(() => {
453
+ const proc = new EventEmitter();
454
+ proc.kill = vi.fn();
455
+ setImmediate(() => proc.emit("exit", 1));
456
+ return proc;
457
+ });
458
+
459
+ const result = await validateCliPresenceAsync();
460
+ expect(result.ok).toBe(false);
461
+ });
462
+ });
463
+
464
+ describe("validateCliAuthAsync", () => {
465
+ beforeEach(() => {
466
+ vi.clearAllMocks();
467
+ });
468
+
469
+ it("resolves true when droid auth status exits 0", async () => {
470
+ const EventEmitter = require("node:events");
471
+ (spawn as any).mockImplementationOnce(() => {
472
+ const proc = new EventEmitter();
473
+ proc.kill = vi.fn();
474
+ setImmediate(() => proc.emit("exit", 0));
475
+ return proc;
476
+ });
477
+
478
+ expect(await validateCliAuthAsync()).toBe(true);
479
+ const args = (spawn as any).mock.calls[0][1] as string[];
480
+ expect(args).toEqual(["auth", "status"]);
481
+ });
482
+
483
+ it("resolves false and warns when droid auth status fails", async () => {
484
+ const EventEmitter = require("node:events");
485
+ (spawn as any).mockImplementationOnce(() => {
486
+ const proc = new EventEmitter();
487
+ proc.kill = vi.fn();
488
+ setImmediate(() => proc.emit("exit", 1));
489
+ return proc;
490
+ });
491
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
492
+
493
+ expect(await validateCliAuthAsync()).toBe(false);
494
+ expect(warnSpy).toHaveBeenCalledWith(
495
+ expect.stringContaining("not authenticated"),
496
+ );
497
+ warnSpy.mockRestore();
498
+ });
499
+ });
500
+
501
+ describe("CLI flags", () => {
502
+ beforeEach(() => {
503
+ vi.clearAllMocks();
504
+ });
505
+
506
+ it("spawnDroid does NOT include --permission-mode or dontAsk in args", () => {
507
+ spawnDroid("claude-sonnet-4-5-20250929");
508
+ const args = (spawn as any).mock.calls[0][1] as string[];
509
+
510
+ expect(args).not.toContain("--permission-mode");
511
+ expect(args).not.toContain("dontAsk");
512
+ });
513
+
514
+ it("spawnDroid does NOT include --permission-prompt-tool in args", () => {
515
+ spawnDroid("claude-sonnet-4-5-20250929");
516
+ const args = (spawn as any).mock.calls[0][1] as string[];
517
+
518
+ expect(args).not.toContain("--permission-prompt-tool");
519
+ });
520
+ });
521
+
522
+ describe("mcp-config flag", () => {
523
+ beforeEach(() => {
524
+ vi.clearAllMocks();
525
+ });
526
+
527
+ it("spawnDroid with mcpConfigPath includes --mcp-config followed by the path", () => {
528
+ spawnDroid("claude-sonnet-4-5-20250929", undefined, {
529
+ mcpConfigPath: "/tmp/mcp-config.json",
530
+ });
531
+ const args = (spawn as any).mock.calls[0][1] as string[];
532
+
533
+ expect(args).toContain("--mcp-config");
534
+ const idx = args.indexOf("--mcp-config");
535
+ expect(args[idx + 1]).toBe("/tmp/mcp-config.json");
536
+ });
537
+
538
+ it("spawnDroid without mcpConfigPath does NOT include --mcp-config in args", () => {
539
+ spawnDroid("claude-sonnet-4-5-20250929");
540
+ const args = (spawn as any).mock.calls[0][1] as string[];
541
+
542
+ expect(args).not.toContain("--mcp-config");
543
+ });
544
+
545
+ it("spawnDroid NEVER includes --strict-mcp-config in args", () => {
546
+ spawnDroid("claude-sonnet-4-5-20250929", undefined, {
547
+ mcpConfigPath: "/tmp/mcp-config.json",
548
+ });
549
+ const args = (spawn as any).mock.calls[0][1] as string[];
550
+
551
+ expect(args).not.toContain("--strict-mcp-config");
552
+ });
553
+
554
+ it("backward compatibility - existing calls with only effort/cwd still work", () => {
555
+ spawnDroid("claude-sonnet-4-5-20250929", "system prompt", {
556
+ cwd: "/path",
557
+ effort: "high",
558
+ });
559
+ const args = (spawn as any).mock.calls[0][1] as string[];
560
+
561
+ expect(args).toContain("--append-system-prompt");
562
+ expect(args).toContain("--effort");
563
+ expect(args).not.toContain("--mcp-config");
564
+ expect(args).not.toContain("--permission-prompt-tool");
565
+ });
566
+ });
567
+
568
+ describe("forceKillProcess", () => {
569
+ it("calls proc.kill('SIGKILL') on live process", () => {
570
+ const proc = {
571
+ killed: false,
572
+ exitCode: null,
573
+ kill: vi.fn(),
574
+ } as unknown as ChildProcess;
575
+
576
+ forceKillProcess(proc);
577
+
578
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
579
+ });
580
+
581
+ it("no-ops when proc.killed is true", () => {
582
+ const proc = {
583
+ killed: true,
584
+ exitCode: null,
585
+ kill: vi.fn(),
586
+ } as unknown as ChildProcess;
587
+
588
+ forceKillProcess(proc);
589
+
590
+ expect(proc.kill).not.toHaveBeenCalled();
591
+ });
592
+
593
+ it("no-ops when proc.exitCode is not null", () => {
594
+ const proc = {
595
+ killed: false,
596
+ exitCode: 0,
597
+ kill: vi.fn(),
598
+ } as unknown as ChildProcess;
599
+
600
+ forceKillProcess(proc);
601
+
602
+ expect(proc.kill).not.toHaveBeenCalled();
603
+ });
604
+ });
605
+
606
+ describe("process registry", () => {
607
+ beforeEach(() => {
608
+ // Clear registry between tests
609
+ killAllProcesses();
610
+ vi.clearAllMocks();
611
+ });
612
+
613
+ it("registerProcess adds proc and killAllProcesses kills it", () => {
614
+ const EventEmitter = require("node:events");
615
+ const proc = new EventEmitter();
616
+ proc.killed = false;
617
+ proc.exitCode = null;
618
+ proc.kill = vi.fn(() => {
619
+ proc.killed = true;
620
+ });
621
+
622
+ registerProcess(proc as unknown as ChildProcess);
623
+ killAllProcesses();
624
+
625
+ expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
626
+ });
627
+
628
+ it("proc exit event removes from registry", () => {
629
+ const EventEmitter = require("node:events");
630
+ const proc = new EventEmitter();
631
+ proc.killed = false;
632
+ proc.exitCode = null;
633
+ proc.kill = vi.fn(() => {
634
+ proc.killed = true;
635
+ });
636
+
637
+ registerProcess(proc as unknown as ChildProcess);
638
+
639
+ // Simulate natural exit
640
+ proc.exitCode = 0;
641
+ proc.emit("exit", 0, null);
642
+
643
+ // Clear mock to check killAllProcesses doesn't call kill again
644
+ proc.kill.mockClear();
645
+ proc.killed = false;
646
+ proc.exitCode = null;
647
+
648
+ killAllProcesses();
649
+
650
+ // Should NOT have been killed since it was removed on exit
651
+ expect(proc.kill).not.toHaveBeenCalled();
652
+ });
653
+
654
+ it("killAllProcesses clears set and handles already-dead processes", () => {
655
+ const EventEmitter = require("node:events");
656
+ const proc1 = new EventEmitter();
657
+ proc1.killed = true; // already dead
658
+ proc1.exitCode = null;
659
+ proc1.kill = vi.fn();
660
+
661
+ const proc2 = new EventEmitter();
662
+ proc2.killed = false;
663
+ proc2.exitCode = 1; // already exited
664
+ proc2.kill = vi.fn();
665
+
666
+ const proc3 = new EventEmitter();
667
+ proc3.killed = false;
668
+ proc3.exitCode = null; // alive
669
+ proc3.kill = vi.fn(() => {
670
+ proc3.killed = true;
671
+ });
672
+
673
+ registerProcess(proc1 as unknown as ChildProcess);
674
+ registerProcess(proc2 as unknown as ChildProcess);
675
+ registerProcess(proc3 as unknown as ChildProcess);
676
+
677
+ killAllProcesses();
678
+
679
+ // Already dead -- forceKillProcess should no-op
680
+ expect(proc1.kill).not.toHaveBeenCalled();
681
+ expect(proc2.kill).not.toHaveBeenCalled();
682
+ // Live process should be killed
683
+ expect(proc3.kill).toHaveBeenCalledWith("SIGKILL");
684
+
685
+ // Calling again should not kill anything (set was cleared)
686
+ proc3.kill.mockClear();
687
+ proc3.killed = false;
688
+ proc3.exitCode = null;
689
+ killAllProcesses();
690
+ expect(proc3.kill).not.toHaveBeenCalled();
691
+ });
692
+ });
693
+
694
+ describe("resume session flag", () => {
695
+ beforeEach(() => {
696
+ vi.clearAllMocks();
697
+ });
698
+
699
+ it("includes --resume followed by session ID when resumeSessionId is provided", () => {
700
+ spawnDroid("claude-sonnet-4-5-20250929", undefined, {
701
+ resumeSessionId: "session-abc-123",
702
+ });
703
+ const args = (spawn as any).mock.calls[0][1] as string[];
704
+
705
+ expect(args).toContain("--resume");
706
+ const idx = args.indexOf("--resume");
707
+ expect(args[idx + 1]).toBe("session-abc-123");
708
+ });
709
+
710
+ it("does NOT include --resume when resumeSessionId is undefined", () => {
711
+ spawnDroid("claude-sonnet-4-5-20250929");
712
+ const args = (spawn as any).mock.calls[0][1] as string[];
713
+
714
+ expect(args).not.toContain("--resume");
715
+ });
716
+
717
+ it("includes both --resume and --effort when both are provided", () => {
718
+ spawnDroid("claude-sonnet-4-5-20250929", undefined, {
719
+ resumeSessionId: "session-abc",
720
+ effort: "high",
721
+ });
722
+ const args = (spawn as any).mock.calls[0][1] as string[];
723
+
724
+ expect(args).toContain("--resume");
725
+ expect(args).toContain("--effort");
726
+ });
727
+
728
+ it("includes both --resume and --mcp-config when both are provided", () => {
729
+ spawnDroid("claude-sonnet-4-5-20250929", undefined, {
730
+ resumeSessionId: "session-abc",
731
+ mcpConfigPath: "/tmp/mcp.json",
732
+ });
733
+ const args = (spawn as any).mock.calls[0][1] as string[];
734
+
735
+ expect(args).toContain("--resume");
736
+ expect(args).toContain("--mcp-config");
737
+ });
738
+ });
739
+
740
+ describe("cleanupSystemPromptFile", () => {
741
+ beforeEach(() => {
742
+ vi.clearAllMocks();
743
+ mocks.unlinkSync.mockReset();
744
+ mocks.tmpdir.mockReset();
745
+ mocks.tmpdir.mockReturnValue("/mock-tmp");
746
+ });
747
+
748
+ it("deletes the temp file when it exists", () => {
749
+ cleanupSystemPromptFile();
750
+
751
+ expect(mocks.unlinkSync).toHaveBeenCalledWith(
752
+ `/mock-tmp/droid-cli-sysprompt-${process.pid}.txt`,
753
+ );
754
+ });
755
+
756
+ it("does not throw when file does not exist", () => {
757
+ mocks.unlinkSync.mockImplementation(() => {
758
+ throw new Error("ENOENT");
759
+ });
760
+
761
+ expect(() => cleanupSystemPromptFile()).not.toThrow();
762
+ });
763
+ });
764
+
765
+ describe("discoverDroidModels", () => {
766
+ beforeEach(() => {
767
+ vi.clearAllMocks();
768
+ });
769
+
770
+ it("parses model ids from JSON output", async () => {
771
+ (spawn as any).mockImplementationOnce(() => {
772
+ const EventEmitter = require("node:events");
773
+ const proc = new EventEmitter();
774
+ proc.stdout = new EventEmitter();
775
+ proc.stderr = new EventEmitter();
776
+ setTimeout(() => {
777
+ proc.stdout.emit("data", Buffer.from('[{"id":"droid-pro"},{"name":"droid-max"}]'));
778
+ proc.emit("exit", 0);
779
+ }, 0);
780
+ return proc;
781
+ });
782
+
783
+ await expect(discoverDroidModels()).resolves.toEqual(["droid-pro", "droid-max"]);
784
+ });
785
+
786
+ it("falls back across attempts and parses newline output", async () => {
787
+ (spawn as any)
788
+ .mockImplementationOnce(() => {
789
+ const EventEmitter = require("node:events");
790
+ const proc = new EventEmitter();
791
+ proc.stdout = new EventEmitter();
792
+ proc.stderr = new EventEmitter();
793
+ setTimeout(() => proc.emit("exit", 1), 0);
794
+ return proc;
795
+ })
796
+ .mockImplementationOnce(() => {
797
+ const EventEmitter = require("node:events");
798
+ const proc = new EventEmitter();
799
+ proc.stdout = new EventEmitter();
800
+ proc.stderr = new EventEmitter();
801
+ setTimeout(() => proc.emit("exit", 1), 0);
802
+ return proc;
803
+ })
804
+ .mockImplementationOnce(() => {
805
+ const EventEmitter = require("node:events");
806
+ const proc = new EventEmitter();
807
+ proc.stdout = new EventEmitter();
808
+ proc.stderr = new EventEmitter();
809
+ setTimeout(() => {
810
+ proc.stdout.emit("data", Buffer.from("droid-lite\ndroid-lite\ndroid-pro\n"));
811
+ proc.emit("exit", 0);
812
+ }, 0);
813
+ return proc;
814
+ });
815
+
816
+ await expect(discoverDroidModels()).resolves.toEqual(["droid-lite", "droid-pro"]);
817
+ });
818
+ });