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.
- package/README.md +186 -58
- package/package.json +5 -5
- package/src/constants.ts +8 -0
- package/src/interfaces/server.ts +7 -0
- package/src/interfaces/settings.ts +67 -1
- package/src/managers/command.ts +110 -31
- package/src/managers/events.ts +2 -2
- package/src/managers/server.ts +14 -11
- package/src/managers/settings.ts +120 -45
- package/src/models/baseModel.ts +37 -7
- package/src/models/routerModel.ts +2 -1
- package/src/server.ts +62 -23
- package/src/sse/manager.ts +9 -8
- package/src/ui/dialog.ts +290 -0
- package/src/ui/overrideEntryEditor.ts +119 -0
- package/src/ui/overrideSettingsList.ts +710 -0
- package/src/ui/serverListEditor.ts +19 -441
- package/src/ui/serverSettingsList.ts +513 -0
- package/src/ui/strings.ts +127 -0
- package/src/utils/health.ts +48 -0
- package/src/utils/settingsStore.ts +2 -6
- package/tests/commandManager.test.ts +132 -16
- package/tests/dialog.test.ts +228 -0
- package/tests/events.test.ts +12 -9
- package/tests/health.test.ts +116 -0
- package/tests/legacyModel.test.ts +4 -19
- package/tests/mocks.ts +12 -8
- package/tests/overrides.test.ts +384 -0
- package/tests/server.test.ts +59 -16
- package/tests/serverManager.test.ts +28 -62
- package/tests/settings.test.ts +489 -43
- package/tests/settingsStore.test.ts +0 -19
- package/tests/singleModel.test.ts +32 -0
- package/tests/sseManager.test.ts +4 -4
- package/tests/serverListEditor.test.ts +0 -637
|
@@ -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
|
|
14
|
-
import {
|
|
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(
|
|
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(["
|
|
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(()
|
|
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(()
|
|
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
|
-
|
|
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
|
-
) =>
|
|
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(
|
|
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
|
|
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(()
|
|
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).
|
|
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
|
+
});
|
package/tests/events.test.ts
CHANGED
|
@@ -299,7 +299,7 @@ describe("EventManager.onBeforeProviderRequest", () => {
|
|
|
299
299
|
|
|
300
300
|
describe("EventManager.onModelSelect", () => {
|
|
301
301
|
beforeEach(() => {
|
|
302
|
-
vi.mocked(settingsStub.resolveReactToModelSelect).
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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
|
+
});
|