pi-llama-cpp 0.11.0 → 0.13.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.
@@ -1,4 +1,5 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { initTheme } from "@earendil-works/pi-coding-agent";
2
3
  import type { KeybindingsManager, TUI } from "@earendil-works/pi-tui";
3
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
5
  import { Action } from "../src/enums/action";
@@ -10,8 +11,8 @@ import {
10
11
  } from "../src/managers/command";
11
12
  import { ServerManager } from "../src/managers/server";
12
13
  import type { LlamaSettingsManager } from "../src/managers/settings";
13
- import type { Server } from "../src/server";
14
- import { ServerListEditor } from "../src/ui/serverListEditor";
14
+ import * as serverListEditor from "../src/ui/serverListEditor";
15
+ import { ServerSettingsList } from "../src/ui/serverSettingsList";
15
16
  import {
16
17
  createMockCtx,
17
18
  createMockModel,
@@ -22,10 +23,30 @@ import {
22
23
  } from "./mocks";
23
24
 
24
25
  beforeEach(() => {
26
+ initTheme();
25
27
  vi.clearAllMocks();
26
28
  mockRpc.mockResolvedValue({ data: [] });
29
+ // Mock health checks to return "healthy" immediately
30
+ vi.spyOn(serverListEditor, "getServerHealthEmoji").mockResolvedValue("🟢");
27
31
  });
28
32
 
33
+ /**
34
+ * Waits for a ServerSettingsList to finish its async initialization
35
+ * (health checks + SettingsList construction).
36
+ */
37
+ const waitForEditor = (editor: ServerSettingsList): Promise<void> =>
38
+ new Promise((resolve) => {
39
+ const check = () => {
40
+ // @ts-expect-error settingsList is private
41
+ if (editor.settingsList) {
42
+ resolve();
43
+ } else {
44
+ setTimeout(check, 10);
45
+ }
46
+ };
47
+ check();
48
+ });
49
+
29
50
  describe("CommandManager", () => {
30
51
  let serverManager: ServerManager;
31
52
  let commandManager: CommandManager;
@@ -42,12 +63,13 @@ describe("CommandManager", () => {
42
63
  describe("getArgumentCompletions", () => {
43
64
  it("should provide completions for /models", () => {
44
65
  const completions = commandManager.getArgumentCompletions("");
45
- expect(completions).toHaveLength(4);
66
+ expect(completions).toHaveLength(5);
46
67
  expect(completions?.map((c) => c.value)).toEqual([
47
68
  "info",
48
69
  "unload",
49
- "servers",
50
70
  "settings",
71
+ "servers",
72
+ "overrides",
51
73
  ]);
52
74
  });
53
75
 
@@ -64,7 +86,7 @@ describe("CommandManager", () => {
64
86
 
65
87
  it("should provide the server/settings completions by prefix", () => {
66
88
  const completions = commandManager.getArgumentCompletions("s");
67
- expect(completions?.map((c) => c.value)).toEqual(["servers", "settings"]);
89
+ expect(completions?.map((c) => c.value)).toEqual(["settings", "servers"]);
68
90
  });
69
91
  });
70
92
 
@@ -77,7 +99,7 @@ describe("CommandManager", () => {
77
99
  models: [model1, model2],
78
100
  });
79
101
  const unloadSettings = makeSettingsStub({
80
- resolveServers: vi.fn((): Server[] => [server]),
102
+ resolveServers: vi.fn(async () => [server]),
81
103
  });
82
104
  serverManager = new ServerManager(unloadSettings);
83
105
  commandManager = new CommandManager(serverManager, unloadSettings);
@@ -107,7 +129,7 @@ describe("CommandManager", () => {
107
129
  models: [model1, model2],
108
130
  });
109
131
  const infoSettings = makeSettingsStub({
110
- resolveServers: vi.fn((): Server[] => [server]),
132
+ resolveServers: vi.fn(async () => [server]),
111
133
  });
112
134
  serverManager = new ServerManager(infoSettings);
113
135
  commandManager = new CommandManager(serverManager, infoSettings);
@@ -133,8 +155,8 @@ describe("CommandManager", () => {
133
155
  expect(formatMs(1500)).toBe("1500ms");
134
156
  });
135
157
 
136
- it("should build one item per editable scalar field", () => {
137
- const items = buildSettingsItems(settingsStub);
158
+ it("should build one item per editable scalar field", async () => {
159
+ const items = await buildSettingsItems(settingsStub);
138
160
  expect(items.map((i) => i.id)).toEqual([
139
161
  "reactToModelSelect",
140
162
  "autoloadOnMessage",
@@ -220,7 +242,9 @@ describe("CommandManager", () => {
220
242
  matches: vi.fn(
221
243
  (data: string, name: string) =>
222
244
  (data === ENTER && name === "tui.select.confirm") ||
223
- (data === ESC && name === "tui.select.cancel"),
245
+ (data === ESC && name === "tui.select.cancel") ||
246
+ (data === "\x1b[A" && name === "tui.select.up") ||
247
+ (data === "\x1b[B" && name === "tui.select.down"),
224
248
  ),
225
249
  }) as unknown as KeybindingsManager;
226
250
 
@@ -256,7 +280,7 @@ describe("CommandManager", () => {
256
280
 
257
281
  it("should wire the editor to the merged servers and the write path", async () => {
258
282
  const editorSettings = makeSettingsStub({
259
- llamaServers: [{ url: "http://seed:1" }],
283
+ getLlamaServers: vi.fn(async () => [{ url: "http://seed:1" }]),
260
284
  });
261
285
  commandManager = new CommandManager(serverManager, editorSettings);
262
286
  const ctx = createMockCtx(() => null);
@@ -269,7 +293,7 @@ describe("CommandManager", () => {
269
293
  theme: Theme,
270
294
  kb: KeybindingsManager,
271
295
  done: (result: undefined) => void,
272
- ) => ServerListEditor;
296
+ ) => ServerSettingsList;
273
297
  const done = vi.fn();
274
298
  const editor = factory(
275
299
  { requestRender: vi.fn() } as unknown as TUI,
@@ -284,14 +308,23 @@ describe("CommandManager", () => {
284
308
  done,
285
309
  );
286
310
 
287
- expect(editor).toBeInstanceOf(ServerListEditor);
311
+ expect(editor).toBeInstanceOf(ServerSettingsList);
312
+ // Wait for async health checks to complete
313
+ await waitForEditor(editor);
288
314
  // Seeded with the merged snapshot
289
315
  expect(editor.render(80).join("\n")).toContain("http://seed:1");
290
316
 
291
- // Add a server through the editor → setLlamaSetting("servers", …)
317
+ // Add a server through the wizard (URL → ID → name) →
318
+ // setLlamaSetting("servers", …) persists only after the final step
292
319
  editor.handleInput("a");
320
+ expect(editor.render(80).join("\n")).toContain("Add server · 1/3");
293
321
  for (const ch of "http://new:2") editor.handleInput(ch);
294
322
  editor.handleInput(ENTER);
323
+ expect(editor.render(80).join("\n")).toContain("Add server · 2/3");
324
+ editor.handleInput(ENTER); // skip optional ID
325
+ expect(editor.render(80).join("\n")).toContain("Add server · 3/3");
326
+ editor.handleInput(ENTER); // skip optional name → persist
327
+ // Wait for async persist and rebuild
295
328
  await new Promise((resolve) => setTimeout(resolve, 0));
296
329
  expect(editorSettings.setLlamaSetting).toHaveBeenCalledWith("servers", [
297
330
  { url: "http://seed:1" },
@@ -302,6 +335,89 @@ describe("CommandManager", () => {
302
335
  editor.handleInput(ESC);
303
336
  expect(done).toHaveBeenCalledTimes(1);
304
337
  });
338
+
339
+ it("should persist nothing when the add wizard is cancelled", async () => {
340
+ const editorSettings = makeSettingsStub({
341
+ getLlamaServers: vi.fn(async () => [{ url: "http://seed:1" }]),
342
+ });
343
+ commandManager = new CommandManager(serverManager, editorSettings);
344
+ const ctx = createMockCtx(() => null);
345
+
346
+ await commandManager.handleCommand("servers", ctx as any, mockPi as any);
347
+
348
+ const factory = vi.mocked(ctx.ui.custom).mock.calls[0][0] as (
349
+ tui: TUI,
350
+ theme: Theme,
351
+ kb: KeybindingsManager,
352
+ done: (result: undefined) => void,
353
+ ) => ServerSettingsList;
354
+ const editor = factory(
355
+ { requestRender: vi.fn() } as unknown as TUI,
356
+ createMockTheme(),
357
+ createMockKeybindings(),
358
+ vi.fn(),
359
+ );
360
+
361
+ // Walk to wizard step 2, then abort — nothing must be persisted
362
+ editor.handleInput("a");
363
+ for (const ch of "http://new:2") editor.handleInput(ch);
364
+ editor.handleInput(ENTER);
365
+ editor.handleInput(ESC);
366
+ await new Promise((resolve) => setTimeout(resolve, 0));
367
+
368
+ expect(editorSettings.setLlamaSetting).not.toHaveBeenCalled();
369
+ // Back to the list view
370
+ expect(editor.render(80).join("\n")).toContain("http://seed:1");
371
+ });
372
+
373
+ it("should delete the selected server after confirming", async () => {
374
+ const editorSettings = makeSettingsStub({
375
+ getLlamaServers: vi.fn(async () => [
376
+ { url: "http://seed:1" },
377
+ { url: "http://doomed:2" },
378
+ ]),
379
+ });
380
+ commandManager = new CommandManager(serverManager, editorSettings);
381
+ const ctx = createMockCtx(() => null);
382
+
383
+ await commandManager.handleCommand("servers", ctx as any, mockPi as any);
384
+
385
+ const factory = vi.mocked(ctx.ui.custom).mock.calls[0][0] as (
386
+ tui: TUI,
387
+ theme: Theme,
388
+ kb: KeybindingsManager,
389
+ done: (result: undefined) => void,
390
+ ) => ServerSettingsList;
391
+ const editor = factory(
392
+ { requestRender: vi.fn() } as unknown as TUI,
393
+ createMockTheme(),
394
+ createMockKeybindings(),
395
+ vi.fn(),
396
+ );
397
+
398
+ // Wait for async health checks to complete
399
+ await waitForEditor(editor);
400
+
401
+ // Move to the second row and open the confirm dialog
402
+ editor.handleInput("\x1b[B");
403
+ editor.handleInput("d");
404
+ expect(editor.render(80).join("\n")).toContain(
405
+ 'Delete "http://doomed:2"?',
406
+ );
407
+
408
+ // Esc keeps the server
409
+ editor.handleInput(ESC);
410
+ await new Promise((resolve) => setTimeout(resolve, 0));
411
+ expect(editorSettings.setLlamaSetting).not.toHaveBeenCalled();
412
+
413
+ // Re-open and confirm the default "Delete" selection
414
+ editor.handleInput("d");
415
+ editor.handleInput(ENTER);
416
+ await new Promise((resolve) => setTimeout(resolve, 0));
417
+ expect(editorSettings.setLlamaSetting).toHaveBeenCalledWith("servers", [
418
+ { url: "http://seed:1" },
419
+ ]);
420
+ });
305
421
  });
306
422
 
307
423
  describe("/models interactive menu", () => {
@@ -321,7 +437,7 @@ describe("CommandManager", () => {
321
437
  }),
322
438
  );
323
439
  const settingsStub = makeSettingsStub({
324
- resolveServers: vi.fn((): Server[] => servers),
440
+ resolveServers: vi.fn(async () => servers),
325
441
  });
326
442
  const serverManager = new ServerManager(settingsStub);
327
443
  return {
@@ -432,7 +548,7 @@ describe("CommandManager", () => {
432
548
  baseUrl: "http://127.0.0.1:8081",
433
549
  models: [modelB],
434
550
  });
435
- vi.mocked(settingsStub.resolveServers).mockReturnValue([
551
+ vi.mocked(settingsStub.resolveServers).mockResolvedValue([
436
552
  ...servers,
437
553
  serverB,
438
554
  ]);
@@ -0,0 +1,228 @@
1
+ import { initTheme, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ getKeybindings,
4
+ type KeybindingsManager,
5
+ type TUI,
6
+ } from "@earendil-works/pi-tui";
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { ConfirmDialog, InputDialog } from "../src/ui/dialog";
9
+ import { createOverrideSettingsList } from "../src/ui/overrideSettingsList";
10
+ import { ServerSettingsList } from "../src/ui/serverSettingsList";
11
+
12
+ beforeEach(() => {
13
+ initTheme();
14
+ vi.clearAllMocks();
15
+ });
16
+
17
+ const ESC = "\x1b";
18
+ const ENTER = "\r";
19
+
20
+ const createMockTheme = (): Theme =>
21
+ ({
22
+ fg: (_color: string, text: string) => text,
23
+ bold: (text: string) => text,
24
+ }) as unknown as Theme;
25
+
26
+ const createMockTui = (): TUI => ({ requestRender: vi.fn() }) as unknown as TUI;
27
+
28
+ const createKeybindings = (): KeybindingsManager => getKeybindings();
29
+
30
+ describe("InputDialog", () => {
31
+ const build = (
32
+ overrides: Partial<ConstructorParameters<typeof InputDialog>[0]> = {},
33
+ ) => {
34
+ const onSubmit = vi.fn();
35
+ const onCancel = vi.fn();
36
+ const dialog = new InputDialog({
37
+ theme: createMockTheme(),
38
+ tui: createMockTui(),
39
+ title: "Dialog title",
40
+ message: "Server URL",
41
+ placeholder: "http://127.0.0.1:8080",
42
+ validate: (raw) => (raw.startsWith("http") ? raw : null),
43
+ onSubmit,
44
+ onCancel,
45
+ ...overrides,
46
+ });
47
+ return { dialog, onSubmit, onCancel };
48
+ };
49
+
50
+ it("renders the framed title, message, placeholder and key hints", () => {
51
+ const { dialog } = build();
52
+ const rendered = dialog.render(60).join("\n");
53
+ expect(rendered).toContain("Dialog title");
54
+ expect(rendered).toContain("Server URL");
55
+ expect(rendered).toContain("e.g., http://127.0.0.1:8080");
56
+ });
57
+
58
+ it("submits the validated value on Enter", () => {
59
+ const { dialog, onSubmit, onCancel } = build({
60
+ initialValue: "http://x:1",
61
+ });
62
+ dialog.handleInput(ENTER);
63
+ expect(onSubmit).toHaveBeenCalledWith("http://x:1");
64
+ expect(onCancel).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it("keeps the dialog open with an error on invalid input", () => {
68
+ const { dialog, onSubmit, onCancel } = build();
69
+ for (const ch of "no-scheme") dialog.handleInput(ch);
70
+ dialog.handleInput(ENTER);
71
+ expect(onSubmit).not.toHaveBeenCalled();
72
+ expect(dialog.render(60).join("\n")).toContain('Invalid value "no-scheme"');
73
+
74
+ // Esc still cancels after an error
75
+ dialog.handleInput(ESC);
76
+ expect(onCancel).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it("cancels on Esc without submitting", () => {
80
+ const { dialog, onSubmit, onCancel } = build({
81
+ initialValue: "http://x:1",
82
+ });
83
+ dialog.handleInput(ESC);
84
+ expect(onCancel).toHaveBeenCalledTimes(1);
85
+ expect(onSubmit).not.toHaveBeenCalled();
86
+ });
87
+ });
88
+
89
+ describe("ConfirmDialog", () => {
90
+ const build = () => {
91
+ const onConfirm = vi.fn();
92
+ const onCancel = vi.fn();
93
+ const dialog = new ConfirmDialog({
94
+ theme: createMockTheme(),
95
+ tui: createMockTui(),
96
+ title: "Delete server",
97
+ message: 'Delete "http://x:1"?',
98
+ onConfirm,
99
+ onCancel,
100
+ });
101
+ return { dialog, onConfirm, onCancel };
102
+ };
103
+
104
+ it("renders the framed message and both options", () => {
105
+ const { dialog } = build();
106
+ const rendered = dialog.render(60).join("\n");
107
+ expect(rendered).toContain("Delete server");
108
+ expect(rendered).toContain('Delete "http://x:1"?');
109
+ expect(rendered).toContain("Delete");
110
+ expect(rendered).toContain("Cancel");
111
+ });
112
+
113
+ it("confirms with Enter on the default selection", () => {
114
+ const { dialog, onConfirm, onCancel } = build();
115
+ dialog.handleInput(ENTER);
116
+ expect(onConfirm).toHaveBeenCalledTimes(1);
117
+ expect(onCancel).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it("cancels on Esc", () => {
121
+ const { dialog, onConfirm, onCancel } = build();
122
+ dialog.handleInput(ESC);
123
+ expect(onCancel).toHaveBeenCalledTimes(1);
124
+ expect(onConfirm).not.toHaveBeenCalled();
125
+ });
126
+ });
127
+
128
+ describe("ServerSettingsList add wizard", () => {
129
+ const buildEditor = (servers: { url: string; id?: string }[] = []) => {
130
+ const persist = vi.fn().mockResolvedValue(undefined);
131
+ const done = vi.fn();
132
+ const onError = vi.fn();
133
+ const editor = new ServerSettingsList({
134
+ tui: createMockTui(),
135
+ theme: createMockTheme(),
136
+ keybindings: createKeybindings(),
137
+ servers: servers as never[],
138
+ persist,
139
+ done,
140
+ onError,
141
+ });
142
+ return { editor, persist, done, onError };
143
+ };
144
+
145
+ it("persists only the URL when the optional steps are skipped", async () => {
146
+ const { editor, persist } = buildEditor();
147
+
148
+ editor.handleInput("a");
149
+ for (const ch of "http://x:1") editor.handleInput(ch);
150
+ editor.handleInput(ENTER); // URL → ID
151
+ editor.handleInput(ENTER); // skip ID → name
152
+ editor.handleInput(ENTER); // skip name → persist
153
+ await new Promise((resolve) => setTimeout(resolve, 0));
154
+
155
+ expect(persist).toHaveBeenCalledWith([{ url: "http://x:1" }]);
156
+ });
157
+
158
+ it("persists the URL and ID when the ID step is filled", async () => {
159
+ const { editor, persist } = buildEditor();
160
+
161
+ editor.handleInput("a");
162
+ for (const ch of "http://x:1") editor.handleInput(ch);
163
+ editor.handleInput(ENTER);
164
+ for (const ch of "my-id") editor.handleInput(ch);
165
+ editor.handleInput(ENTER); // ID → name
166
+ editor.handleInput(ENTER); // skip name → persist
167
+ await new Promise((resolve) => setTimeout(resolve, 0));
168
+
169
+ expect(persist).toHaveBeenCalledWith([{ url: "http://x:1", id: "my-id" }]);
170
+ });
171
+
172
+ it("rejects an invalid URL at step 1 and aborts cleanly on Esc", async () => {
173
+ const { editor, persist } = buildEditor();
174
+
175
+ editor.handleInput("a");
176
+ for (const ch of "not-a-url") editor.handleInput(ch);
177
+ editor.handleInput(ENTER);
178
+ expect(persist).not.toHaveBeenCalled();
179
+ expect(editor.render(80).join("\n")).toContain('Invalid value "not-a-url"');
180
+
181
+ editor.handleInput(ESC);
182
+ await new Promise((resolve) => setTimeout(resolve, 0));
183
+ expect(persist).not.toHaveBeenCalled();
184
+ // Back at the (empty) list view
185
+ expect(editor.render(80).join("\n")).toContain("(a) add server");
186
+ });
187
+ });
188
+
189
+ describe("OverrideEntryListEditor cost re-prefill", () => {
190
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
191
+
192
+ it("re-entering a cost field prefills the just-saved value", async () => {
193
+ const persist = vi.fn().mockResolvedValue(undefined);
194
+ const list = createOverrideSettingsList({
195
+ tui: createMockTui(),
196
+ theme: createMockTheme(),
197
+ keybindings: createKeybindings(),
198
+ servers: [
199
+ {
200
+ url: "http://x:1",
201
+ overrides: { "gpt-*": { cost: { input: 0.2 } } },
202
+ },
203
+ ] as never[],
204
+ persist,
205
+ done: vi.fn(),
206
+ onError: vi.fn(),
207
+ onChanged: vi.fn(),
208
+ });
209
+
210
+ const DOWN = "\x1b[B"; // tui.select.down
211
+ const CLEAR = "\x15"; // tui.editor.deleteToLineStart
212
+
213
+ list.handleInput(ENTER); // drill into the server row
214
+ list.handleInput(ENTER); // open the field submenu (cursor: pattern)
215
+ list.handleInput(DOWN); // cursor: cost.input
216
+ list.handleInput(ENTER); // open the input dialog (prefilled "0.2")
217
+ list.handleInput(CLEAR);
218
+ for (const ch of "0.5") list.handleInput(ch);
219
+ list.handleInput(ENTER); // commit
220
+ await flush();
221
+
222
+ // Re-enter the field: the prefill must be the saved value
223
+ list.handleInput(ENTER);
224
+ const rendered = list.render(80).join("\n");
225
+ expect(rendered).toContain("0.5");
226
+ expect(rendered).not.toContain("0.2");
227
+ });
228
+ });
@@ -299,7 +299,7 @@ describe("EventManager.onBeforeProviderRequest", () => {
299
299
 
300
300
  describe("EventManager.onModelSelect", () => {
301
301
  beforeEach(() => {
302
- vi.mocked(settingsStub.resolveReactToModelSelect).mockReturnValue(true);
302
+ vi.mocked(settingsStub.resolveReactToModelSelect).mockResolvedValue(true);
303
303
  });
304
304
 
305
305
  it("should load the model when reactToModelSelect is true", async () => {
@@ -319,7 +319,7 @@ describe("EventManager.onModelSelect", () => {
319
319
  });
320
320
 
321
321
  it("should return early when reactToModelSelect is false", async () => {
322
- vi.mocked(settingsStub.resolveReactToModelSelect).mockReturnValue(false);
322
+ vi.mocked(settingsStub.resolveReactToModelSelect).mockResolvedValue(false);
323
323
 
324
324
  const server = createMockServer({
325
325
  models: ["model-a"].map((id) => createMockModel(id)),
@@ -339,7 +339,7 @@ describe("EventManager.onModelSelect", () => {
339
339
 
340
340
  describe("EventManager.autoLoadIfNeeded", () => {
341
341
  it("should load the model when autoloadOnMessage is true and model is UNLOADED", async () => {
342
- vi.mocked(settingsStub.resolveAutoloadOnMessage).mockReturnValue(true);
342
+ vi.mocked(settingsStub.resolveAutoloadOnMessage).mockResolvedValue(true);
343
343
 
344
344
  const server = createMockServer({
345
345
  models: [
@@ -358,7 +358,7 @@ describe("EventManager.autoLoadIfNeeded", () => {
358
358
  });
359
359
 
360
360
  it("should not load the model when autoloadOnMessage is false", async () => {
361
- vi.mocked(settingsStub.resolveAutoloadOnMessage).mockReturnValue(false);
361
+ vi.mocked(settingsStub.resolveAutoloadOnMessage).mockResolvedValue(false);
362
362
 
363
363
  const server = createMockServer({
364
364
  models: [
@@ -377,7 +377,7 @@ describe("EventManager.autoLoadIfNeeded", () => {
377
377
  });
378
378
 
379
379
  it("should not load the model when model is already LOADED", async () => {
380
- vi.mocked(settingsStub.resolveAutoloadOnMessage).mockReturnValue(true);
380
+ vi.mocked(settingsStub.resolveAutoloadOnMessage).mockResolvedValue(true);
381
381
 
382
382
  const server = createMockServer({
383
383
  models: [
@@ -396,7 +396,7 @@ describe("EventManager.autoLoadIfNeeded", () => {
396
396
  });
397
397
 
398
398
  it("should not load the model when model is SLEEPING", async () => {
399
- vi.mocked(settingsStub.resolveAutoloadOnMessage).mockReturnValue(true);
399
+ vi.mocked(settingsStub.resolveAutoloadOnMessage).mockResolvedValue(true);
400
400
 
401
401
  const server = createMockServer({
402
402
  models: [
@@ -427,7 +427,7 @@ describe("EventManager with a live ServerManager", () => {
427
427
  ],
428
428
  });
429
429
 
430
- vi.mocked(settingsStub.resolveServers).mockReturnValue([serverA]);
430
+ vi.mocked(settingsStub.resolveServers).mockResolvedValue([serverA]);
431
431
  const serverManager = new ServerManager(settingsStub);
432
432
  const mockPi = { registerProvider: vi.fn(), unregisterProvider: vi.fn() };
433
433
  await serverManager.update(mockPi as any);
@@ -435,7 +435,10 @@ describe("EventManager with a live ServerManager", () => {
435
435
  const eventManager = new EventManager(serverManager, settingsStub);
436
436
 
437
437
  // Second scan adds serverB — no manager re-construction
438
- vi.mocked(settingsStub.resolveServers).mockReturnValue([serverA, serverB]);
438
+ vi.mocked(settingsStub.resolveServers).mockResolvedValue([
439
+ serverA,
440
+ serverB,
441
+ ]);
439
442
  await serverManager.update(mockPi as any);
440
443
 
441
444
  // onBeforeProviderRequest sees the new server's models
@@ -447,7 +450,7 @@ describe("EventManager with a live ServerManager", () => {
447
450
  expect(result.thinking_budget_tokens).toBe(THINKING_BUDGETS.medium);
448
451
 
449
452
  // onModelSelect sees the new server's models too
450
- vi.mocked(settingsStub.resolveReactToModelSelect).mockReturnValue(true);
453
+ vi.mocked(settingsStub.resolveReactToModelSelect).mockResolvedValue(true);
451
454
  const selectCtx = createMockCtx();
452
455
  await eventManager.onModelSelect(
453
456
  { model: { provider: serverB.providerId, id: "model-b" } } as any,
@@ -0,0 +1,116 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { ServerStatus } from "../src/enums/serverStatus";
3
+ import { checkServerHealth } from "../src/utils/health";
4
+
5
+ /**
6
+ * Stubs `global.fetch` for one test. The probe only reads the parsed body,
7
+ * so a fake response is just a `json()` accessor.
8
+ */
9
+ const stubFetch = (
10
+ impl: (url: string, init?: RequestInit) => Promise<unknown>,
11
+ ) => vi.stubGlobal("fetch", vi.fn(impl));
12
+
13
+ const okResponse = () => ({ json: async () => ({ status: "ok" }) });
14
+
15
+ const URL_ = "http://127.0.0.1:8080";
16
+
17
+ afterEach(() => {
18
+ vi.unstubAllGlobals();
19
+ });
20
+
21
+ describe("checkServerHealth", () => {
22
+ it("should return READY when the payload status is ok", async () => {
23
+ stubFetch(async () => ({ json: async () => ({ status: "ok" }) }));
24
+
25
+ expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.READY);
26
+ });
27
+
28
+ it("should return UNREACHABLE when the payload status is not ok", async () => {
29
+ stubFetch(async () => ({ json: async () => ({ status: "error" }) }));
30
+
31
+ expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.UNREACHABLE);
32
+ });
33
+
34
+ it("should return UNREACHABLE when the payload has no status field", async () => {
35
+ // llama-server answers a loading model with a 503 + error payload
36
+ stubFetch(async () => ({
37
+ json: async () => ({ error: { message: "Loading model" } }),
38
+ }));
39
+
40
+ expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.UNREACHABLE);
41
+ });
42
+
43
+ it("should return UNREACHABLE when the body is not JSON", async () => {
44
+ stubFetch(async () => ({
45
+ json: async () => {
46
+ throw new Error("Unexpected token");
47
+ },
48
+ }));
49
+
50
+ expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.UNREACHABLE);
51
+ });
52
+
53
+ it("should return UNREACHABLE on a network error", async () => {
54
+ stubFetch(async () => {
55
+ throw new Error("connection refused");
56
+ });
57
+
58
+ expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.UNREACHABLE);
59
+ });
60
+
61
+ it("should return TIMEOUT when the fetch aborts with TimeoutError", async () => {
62
+ stubFetch(async () => {
63
+ throw new DOMException("The operation timed out.", "TimeoutError");
64
+ });
65
+
66
+ expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.TIMEOUT);
67
+ });
68
+
69
+ it("should return TIMEOUT when the fetch aborts with AbortError", async () => {
70
+ // Some runtimes surface the timeout as a plain AbortError
71
+ stubFetch(async () => {
72
+ throw new DOMException("The user aborted a request.", "AbortError");
73
+ });
74
+
75
+ expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.TIMEOUT);
76
+ });
77
+
78
+ it("should hit <url>/health", async () => {
79
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
80
+ okResponse(),
81
+ );
82
+ vi.stubGlobal("fetch", fetchMock);
83
+
84
+ await checkServerHealth(URL_, 1000);
85
+
86
+ expect(fetchMock).toHaveBeenCalledWith(`${URL_}/health`, {
87
+ signal: expect.any(AbortSignal),
88
+ headers: undefined,
89
+ });
90
+ });
91
+
92
+ it("should send the API key as a Bearer token when provided", async () => {
93
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
94
+ okResponse(),
95
+ );
96
+ vi.stubGlobal("fetch", fetchMock);
97
+
98
+ await checkServerHealth(URL_, 1000, "secret-key");
99
+
100
+ expect(fetchMock).toHaveBeenCalledWith(`${URL_}/health`, {
101
+ signal: expect.any(AbortSignal),
102
+ headers: { Authorization: "Bearer secret-key" },
103
+ });
104
+ });
105
+
106
+ it("should omit the Authorization header when no key is given", async () => {
107
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
108
+ okResponse(),
109
+ );
110
+ vi.stubGlobal("fetch", fetchMock);
111
+
112
+ await checkServerHealth(URL_, 1000);
113
+
114
+ expect(fetchMock.mock.calls[0]?.[1]?.headers).toBeUndefined();
115
+ });
116
+ });