pi-llama-cpp 0.9.2 → 0.11.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 +163 -27
- package/package.json +4 -3
- package/src/api/client.ts +76 -32
- package/src/constants.ts +28 -3
- package/src/index.ts +4 -12
- package/src/interfaces/events.ts +14 -3
- package/src/interfaces/server.ts +20 -0
- package/src/interfaces/settings.ts +69 -0
- package/src/managers/command.ts +285 -43
- package/src/managers/events.ts +50 -12
- package/src/managers/server.ts +90 -17
- package/src/managers/settings.ts +268 -0
- package/src/models/baseModel.ts +32 -17
- package/src/models/legacyModel.ts +2 -2
- package/src/models/routerModel.ts +3 -3
- package/src/server.ts +86 -21
- package/src/sse/client.ts +28 -16
- package/src/sse/manager.ts +23 -10
- package/src/ui/serverListEditor.ts +481 -0
- package/src/utils/errors.ts +5 -0
- package/src/utils/settingsStore.ts +60 -0
- package/src/utils/urls.ts +16 -0
- package/tests/commandManager.test.ts +256 -11
- package/tests/events.test.ts +229 -64
- package/tests/mocks.ts +145 -32
- package/tests/server.test.ts +54 -6
- package/tests/serverListEditor.test.ts +637 -0
- package/tests/serverManager.test.ts +282 -39
- package/tests/settings.test.ts +793 -0
- package/tests/settingsStore.test.ts +209 -0
- package/tests/sseManager.test.ts +98 -0
- package/src/interfaces/auth.ts +0 -6
- package/src/resolver.ts +0 -149
- package/src/utils/cache.ts +0 -39
- package/src/utils/mutex.ts +0 -24
- package/tests/resolver.test.ts +0 -184
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
API_KEY_PLACEHOLDER,
|
|
5
|
+
LLAMA_SERVER_URL,
|
|
6
|
+
POLLING_TIMEOUT,
|
|
7
|
+
PROVIDER_PREFIX,
|
|
8
|
+
SERVER_TIMEOUT,
|
|
9
|
+
} from "../src/constants";
|
|
10
|
+
import { settings } from "../src/managers/settings";
|
|
11
|
+
import { Server } from "../src/server";
|
|
12
|
+
|
|
13
|
+
// Hoisted mock instances — survives vi.resetModules()
|
|
14
|
+
const mockReadStoredCredential = vi.hoisted(() => vi.fn());
|
|
15
|
+
|
|
16
|
+
const mockSettingsManager = vi.hoisted(() => ({
|
|
17
|
+
getProjectSettings: vi.fn(),
|
|
18
|
+
getGlobalSettings: vi.fn(),
|
|
19
|
+
getDefaultThinkingLevel: vi.fn(),
|
|
20
|
+
getThinkingBudgets: vi.fn(),
|
|
21
|
+
reload: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// Mock getAgentDir, readStoredCredential, and SettingsManager before importing resolver
|
|
25
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
26
|
+
getAgentDir: vi.fn().mockReturnValue("/fake/agent/dir"),
|
|
27
|
+
readStoredCredential: (...args: unknown[]) =>
|
|
28
|
+
mockReadStoredCredential(...args),
|
|
29
|
+
SettingsManager: {
|
|
30
|
+
create: vi.fn().mockReturnValue(mockSettingsManager),
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock("node:fs/promises", () => ({
|
|
35
|
+
readFile: vi.fn(),
|
|
36
|
+
writeFile: vi.fn(),
|
|
37
|
+
rename: vi.fn(),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// Import mocked modules
|
|
41
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
42
|
+
|
|
43
|
+
describe("URL resolution fallback chain", () => {
|
|
44
|
+
const mockGetAgentDir = vi.mocked(getAgentDir);
|
|
45
|
+
const mockGetProjectSettings = vi.mocked(
|
|
46
|
+
mockSettingsManager.getProjectSettings,
|
|
47
|
+
);
|
|
48
|
+
const mockGetGlobalSettings = vi.mocked(
|
|
49
|
+
mockSettingsManager.getGlobalSettings,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
delete process.env.LLAMA_SERVER_URL;
|
|
54
|
+
vi.resetModules();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
vi.clearAllMocks();
|
|
59
|
+
mockGetAgentDir.mockReturnValue("/fake/agent/dir");
|
|
60
|
+
// Default: no settings found
|
|
61
|
+
mockGetProjectSettings.mockReturnValue({});
|
|
62
|
+
mockGetGlobalSettings.mockReturnValue({});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should return default URL when no config is found", async () => {
|
|
66
|
+
// Ensure env var is not set (and not inherited from environment)
|
|
67
|
+
delete process.env.LLAMA_SERVER_URL;
|
|
68
|
+
|
|
69
|
+
const result = settings.resolveUrls();
|
|
70
|
+
|
|
71
|
+
expect(result).toEqual([LLAMA_SERVER_URL]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should prioritize env variable over project config", async () => {
|
|
75
|
+
mockGetProjectSettings.mockReturnValue({
|
|
76
|
+
llamaServerUrl: "http://localhost:9999",
|
|
77
|
+
});
|
|
78
|
+
process.env.LLAMA_SERVER_URL = "http://env-url:8080";
|
|
79
|
+
|
|
80
|
+
const result = settings.resolveUrls();
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual(["http://env-url:8080"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should use env variable when no other config exists", async () => {
|
|
86
|
+
process.env.LLAMA_SERVER_URL = "http://env-url:8080";
|
|
87
|
+
|
|
88
|
+
const result = settings.resolveUrls();
|
|
89
|
+
|
|
90
|
+
expect(result).toEqual(["http://env-url:8080"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should use legacy llamaServerUrl when env is not set", async () => {
|
|
94
|
+
mockGetProjectSettings.mockReturnValue({
|
|
95
|
+
llamaServerUrl: "http://project:9999",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const result = settings.resolveUrls();
|
|
99
|
+
|
|
100
|
+
expect(result).toEqual(["http://project:9999"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should use global settings when no project config or env exists", async () => {
|
|
104
|
+
mockGetGlobalSettings.mockReturnValue({
|
|
105
|
+
llamaServerUrl: "http://global:8080",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = settings.resolveUrls();
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual(["http://global:8080"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should strip trailing slashes from resolved URL", async () => {
|
|
114
|
+
process.env.LLAMA_SERVER_URL = "http://localhost:8080/";
|
|
115
|
+
|
|
116
|
+
const result = settings.resolveUrls();
|
|
117
|
+
|
|
118
|
+
expect(result).toEqual(["http://localhost:8080"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should cache the resolved URL on subsequent calls", async () => {
|
|
122
|
+
process.env.LLAMA_SERVER_URL = "http://first:8080";
|
|
123
|
+
|
|
124
|
+
const result1 = settings.resolveUrls();
|
|
125
|
+
const result2 = settings.resolveUrls();
|
|
126
|
+
|
|
127
|
+
expect(result1).toEqual(["http://first:8080"]);
|
|
128
|
+
expect(result2).toEqual(["http://first:8080"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should handle multiple URLs separated by semicolons", async () => {
|
|
132
|
+
process.env.LLAMA_SERVER_URL = "http://first:8080;http://second:9090/";
|
|
133
|
+
|
|
134
|
+
const result = settings.resolveUrls();
|
|
135
|
+
|
|
136
|
+
expect(result).toEqual(["http://first:8080", "http://second:9090"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should drop env URLs without an http(s) scheme, warn, and fall through", async () => {
|
|
140
|
+
process.env.LLAMA_SERVER_URL = "127.0.0.1:8080";
|
|
141
|
+
|
|
142
|
+
const result = settings.resolveUrls();
|
|
143
|
+
|
|
144
|
+
expect(result).toEqual([LLAMA_SERVER_URL]);
|
|
145
|
+
expect(settings.takeWarnings()).toEqual([
|
|
146
|
+
"Ignoring invalid server URL '127.0.0.1:8080' (needs http(s)://)",
|
|
147
|
+
]);
|
|
148
|
+
expect(settings.takeWarnings()).toEqual([]); // drained
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("should drop server entries without an http(s) scheme and warn", async () => {
|
|
152
|
+
mockGetProjectSettings.mockReturnValue({
|
|
153
|
+
llamaSettings: {
|
|
154
|
+
servers: [{ url: "127.0.0.1:8080" }, { url: "http://good:8080/" }],
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const result = settings.resolveUrls();
|
|
159
|
+
|
|
160
|
+
expect(result).toEqual(["http://good:8080"]);
|
|
161
|
+
expect(settings.takeWarnings()).toEqual([
|
|
162
|
+
"Ignoring invalid server URL '127.0.0.1:8080' (needs http(s)://)",
|
|
163
|
+
]);
|
|
164
|
+
expect(settings.takeWarnings()).toEqual([]); // drained
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("llamaSettings.servers resolution", () => {
|
|
169
|
+
const mockGetAgentDir = vi.mocked(getAgentDir);
|
|
170
|
+
const mockGetProjectSettings = vi.mocked(
|
|
171
|
+
mockSettingsManager.getProjectSettings,
|
|
172
|
+
);
|
|
173
|
+
const mockGetGlobalSettings = vi.mocked(
|
|
174
|
+
mockSettingsManager.getGlobalSettings,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
afterEach(() => {
|
|
178
|
+
delete process.env.LLAMA_SERVER_URL;
|
|
179
|
+
vi.resetModules();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
vi.clearAllMocks();
|
|
184
|
+
mockGetAgentDir.mockReturnValue("/fake/agent/dir");
|
|
185
|
+
mockGetProjectSettings.mockReturnValue({});
|
|
186
|
+
mockGetGlobalSettings.mockReturnValue({});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("should resolve URLs from llamaSettings.servers in project config", async () => {
|
|
190
|
+
mockGetProjectSettings.mockReturnValue({
|
|
191
|
+
llamaSettings: {
|
|
192
|
+
servers: [
|
|
193
|
+
{ url: "http://project-server:8080" },
|
|
194
|
+
{ url: "http://project-server2:9090" },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const result = settings.resolveUrls();
|
|
200
|
+
|
|
201
|
+
expect(result).toEqual([
|
|
202
|
+
"http://project-server:8080",
|
|
203
|
+
"http://project-server2:9090",
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should prioritize project servers over global servers", async () => {
|
|
208
|
+
mockGetProjectSettings.mockReturnValue({
|
|
209
|
+
llamaSettings: {
|
|
210
|
+
servers: [{ url: "http://project:8080" }],
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
mockGetGlobalSettings.mockReturnValue({
|
|
214
|
+
llamaSettings: {
|
|
215
|
+
servers: [{ url: "http://global:9090" }],
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const result = settings.resolveUrls();
|
|
220
|
+
|
|
221
|
+
expect(result).toEqual(["http://project:8080"]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should use global servers when no project servers exist", async () => {
|
|
225
|
+
mockGetGlobalSettings.mockReturnValue({
|
|
226
|
+
llamaSettings: {
|
|
227
|
+
servers: [{ url: "http://global:8080" }],
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const result = settings.resolveUrls();
|
|
232
|
+
|
|
233
|
+
expect(result).toEqual(["http://global:8080"]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("should prioritize env variable over servers", async () => {
|
|
237
|
+
process.env.LLAMA_SERVER_URL = "http://env:8080";
|
|
238
|
+
mockGetProjectSettings.mockReturnValue({
|
|
239
|
+
llamaSettings: {
|
|
240
|
+
servers: [{ url: "http://server:9090" }],
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = settings.resolveUrls();
|
|
245
|
+
|
|
246
|
+
expect(result).toEqual(["http://env:8080"]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("should use servers when env is not set", async () => {
|
|
250
|
+
mockGetProjectSettings.mockReturnValue({
|
|
251
|
+
llamaSettings: {
|
|
252
|
+
servers: [{ url: "http://server:9090" }],
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const result = settings.resolveUrls();
|
|
257
|
+
|
|
258
|
+
expect(result).toEqual(["http://server:9090"]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should prioritize servers over legacy llamaServerUrl", async () => {
|
|
262
|
+
mockGetProjectSettings.mockReturnValue({
|
|
263
|
+
llamaServerUrl: "http://legacy:8080",
|
|
264
|
+
llamaSettings: {
|
|
265
|
+
servers: [{ url: "http://server:9090" }],
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const result = settings.resolveUrls();
|
|
270
|
+
|
|
271
|
+
expect(result).toEqual(["http://server:9090"]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("should use legacy llamaServerUrl when servers is empty", async () => {
|
|
275
|
+
mockGetProjectSettings.mockReturnValue({
|
|
276
|
+
llamaServerUrl: "http://legacy:8080",
|
|
277
|
+
llamaSettings: {
|
|
278
|
+
servers: [],
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const result = settings.resolveUrls();
|
|
283
|
+
|
|
284
|
+
expect(result).toEqual(["http://legacy:8080"]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should strip trailing slashes from server URLs", async () => {
|
|
288
|
+
mockGetProjectSettings.mockReturnValue({
|
|
289
|
+
llamaSettings: {
|
|
290
|
+
servers: [{ url: "http://localhost:8080/" }],
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const result = settings.resolveUrls();
|
|
295
|
+
|
|
296
|
+
expect(result).toEqual(["http://localhost:8080"]);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("API key resolution", () => {
|
|
301
|
+
const mockGetAgentDir = vi.mocked(getAgentDir);
|
|
302
|
+
|
|
303
|
+
afterEach(() => {
|
|
304
|
+
vi.resetModules();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
beforeEach(() => {
|
|
308
|
+
vi.clearAllMocks();
|
|
309
|
+
mockGetAgentDir.mockReturnValue("/fake/agent/dir");
|
|
310
|
+
mockReadStoredCredential.mockReturnValue(undefined);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("should return placeholder when credential is not found", () => {
|
|
314
|
+
mockReadStoredCredential.mockReturnValue(undefined);
|
|
315
|
+
|
|
316
|
+
const result = settings.resolveApiKey("llama-server=http://127.0.0.1:8080");
|
|
317
|
+
|
|
318
|
+
expect(result).toEqual(API_KEY_PLACEHOLDER);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("should return placeholder when apiKey is missing from credential", () => {
|
|
322
|
+
mockReadStoredCredential.mockReturnValue({});
|
|
323
|
+
|
|
324
|
+
const result = settings.resolveApiKey("llama-server=http://127.0.0.1:8080");
|
|
325
|
+
|
|
326
|
+
expect(result).toEqual(API_KEY_PLACEHOLDER);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("should return the key when present in credential", () => {
|
|
330
|
+
mockReadStoredCredential.mockReturnValue({ key: "test-api-key" });
|
|
331
|
+
|
|
332
|
+
const result = settings.resolveApiKey("llama-server=http://127.0.0.1:8080");
|
|
333
|
+
|
|
334
|
+
expect(result).toEqual("test-api-key");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("should call readStoredCredential with the provider ID", () => {
|
|
338
|
+
mockReadStoredCredential.mockReturnValue({ key: "test-key" });
|
|
339
|
+
|
|
340
|
+
settings.resolveApiKey("llama-server=http://127.0.0.1:8080");
|
|
341
|
+
|
|
342
|
+
expect(mockReadStoredCredential).toHaveBeenCalledWith(
|
|
343
|
+
"llama-server=http://127.0.0.1:8080",
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("Server with custom id", () => {
|
|
349
|
+
it("should use custom id as providerId when provided", () => {
|
|
350
|
+
const server = new Server(settings, {
|
|
351
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
352
|
+
customId: "my-custom-id",
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(server.providerId).toEqual("my-custom-id");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("should fall back to URL-based providerId when no custom id", () => {
|
|
359
|
+
const server = new Server(settings, { baseUrl: "http://127.0.0.1:8080" });
|
|
360
|
+
|
|
361
|
+
expect(server.providerId).toEqual(
|
|
362
|
+
`${PROVIDER_PREFIX}=http://127.0.0.1:8080`,
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("should try custom id first in getApiKey(), then fall back to URL-based", () => {
|
|
367
|
+
const server = new Server(settings, {
|
|
368
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
369
|
+
customId: "my-custom-id",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Server construction resolves the key eagerly (ApiClient built there);
|
|
373
|
+
// clear so the assertions below observe only the explicit getApiKey() call
|
|
374
|
+
vi.clearAllMocks();
|
|
375
|
+
// Mock: custom id returns placeholder (no key found)
|
|
376
|
+
mockReadStoredCredential
|
|
377
|
+
.mockReturnValueOnce(API_KEY_PLACEHOLDER)
|
|
378
|
+
.mockReturnValueOnce({ key: "fallback-key" });
|
|
379
|
+
|
|
380
|
+
const result = server.getApiKey();
|
|
381
|
+
|
|
382
|
+
expect(result).toEqual("fallback-key");
|
|
383
|
+
expect(mockReadStoredCredential).toHaveBeenNthCalledWith(1, "my-custom-id");
|
|
384
|
+
expect(mockReadStoredCredential).toHaveBeenNthCalledWith(
|
|
385
|
+
2,
|
|
386
|
+
`${PROVIDER_PREFIX}=http://127.0.0.1:8080`,
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("should return custom id key directly when found", () => {
|
|
391
|
+
mockReadStoredCredential.mockReturnValue({ key: "custom-key" });
|
|
392
|
+
|
|
393
|
+
const server = new Server(settings, {
|
|
394
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
395
|
+
customId: "my-custom-id",
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const result = server.getApiKey();
|
|
399
|
+
|
|
400
|
+
expect(result).toEqual("custom-key");
|
|
401
|
+
expect(mockReadStoredCredential).toHaveBeenCalledWith("my-custom-id");
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe("Server with custom name", () => {
|
|
406
|
+
it("should use custom name as suffix in providerName", () => {
|
|
407
|
+
const server = new Server(settings, {
|
|
408
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
409
|
+
customName: "Remote Server",
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
expect(server.providerName).toEqual(`Llama.cpp (Remote Server)`);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("should fall back to URL-based name when no custom name", () => {
|
|
416
|
+
const server = new Server(settings, { baseUrl: "http://127.0.0.1:8080" });
|
|
417
|
+
|
|
418
|
+
expect(server.providerName).toEqual(`Llama.cpp (http://127.0.0.1:8080)`);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("should use custom name even with custom id", () => {
|
|
422
|
+
const server = new Server(settings, {
|
|
423
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
424
|
+
customId: "my-custom-id",
|
|
425
|
+
customName: "Remote Server",
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(server.providerId).toEqual("my-custom-id");
|
|
429
|
+
expect(server.providerName).toEqual(`Llama.cpp (Remote Server)`);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe("reactToModelSelect and autoloadOnMessage fallbacks", () => {
|
|
434
|
+
afterEach(() => {
|
|
435
|
+
vi.resetModules();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("should return true when reactToModelSelect is not set", async () => {
|
|
439
|
+
const { settings } = await import("../src/managers/settings");
|
|
440
|
+
|
|
441
|
+
const result = settings.resolveReactToModelSelect();
|
|
442
|
+
|
|
443
|
+
expect(result).toBe(true);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("should return false when autoloadOnMessage is not set", async () => {
|
|
447
|
+
const { settings } = await import("../src/managers/settings");
|
|
448
|
+
|
|
449
|
+
const result = settings.resolveAutoloadOnMessage();
|
|
450
|
+
|
|
451
|
+
expect(result).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("should return 'asc' when sortBy is not set", async () => {
|
|
455
|
+
const { settings } = await import("../src/managers/settings");
|
|
456
|
+
|
|
457
|
+
const result = settings.resolveSortBy();
|
|
458
|
+
|
|
459
|
+
expect(result).toBe("asc");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("should return user values when set", async () => {
|
|
463
|
+
mockSettingsManager.getProjectSettings.mockReturnValue({
|
|
464
|
+
llamaSettings: {
|
|
465
|
+
reactToModelSelect: false,
|
|
466
|
+
autoloadOnMessage: true,
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const { settings } = await import("../src/managers/settings");
|
|
471
|
+
|
|
472
|
+
expect(settings.resolveReactToModelSelect()).toBe(false);
|
|
473
|
+
expect(settings.resolveAutoloadOnMessage()).toBe(true);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
describe("resolveServers", () => {
|
|
478
|
+
const mockGetAgentDir = vi.mocked(getAgentDir);
|
|
479
|
+
const mockGetProjectSettings = vi.mocked(
|
|
480
|
+
mockSettingsManager.getProjectSettings,
|
|
481
|
+
);
|
|
482
|
+
const mockGetGlobalSettings = vi.mocked(
|
|
483
|
+
mockSettingsManager.getGlobalSettings,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
afterEach(() => {
|
|
487
|
+
delete process.env.LLAMA_SERVER_URL;
|
|
488
|
+
vi.resetModules();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
beforeEach(() => {
|
|
492
|
+
vi.clearAllMocks();
|
|
493
|
+
mockGetAgentDir.mockReturnValue("/fake/agent/dir");
|
|
494
|
+
mockGetProjectSettings.mockReturnValue({});
|
|
495
|
+
mockGetGlobalSettings.mockReturnValue({});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("should use llamaSettings.servers when configured", () => {
|
|
499
|
+
mockGetProjectSettings.mockReturnValue({
|
|
500
|
+
llamaSettings: {
|
|
501
|
+
servers: [
|
|
502
|
+
{ url: "http://custom:8080", id: "my-server", name: "Custom" },
|
|
503
|
+
],
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const result = settings.resolveServers();
|
|
508
|
+
|
|
509
|
+
expect(result).toHaveLength(1);
|
|
510
|
+
expect(result[0].baseUrl).toBe("http://custom:8080");
|
|
511
|
+
expect(result[0].providerId).toBe("my-server");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("should fall back to resolveUrls when servers is empty", async () => {
|
|
515
|
+
process.env.LLAMA_SERVER_URL = "http://env-server:9090";
|
|
516
|
+
|
|
517
|
+
const result = settings.resolveServers();
|
|
518
|
+
|
|
519
|
+
expect(result).toHaveLength(1);
|
|
520
|
+
expect(result[0].baseUrl).toBe("http://env-server:9090");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("should fall back to default URL when no config exists", () => {
|
|
524
|
+
const result = settings.resolveServers();
|
|
525
|
+
|
|
526
|
+
expect(result).toHaveLength(1);
|
|
527
|
+
expect(result[0].baseUrl).toBe(LLAMA_SERVER_URL);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("should apply id/name from llamaSettings.servers as overrides", () => {
|
|
531
|
+
mockGetProjectSettings.mockReturnValue({
|
|
532
|
+
llamaSettings: {
|
|
533
|
+
servers: [
|
|
534
|
+
{ url: "http://127.0.0.1:8080", id: "custom-id", name: "Custom" },
|
|
535
|
+
],
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const result = settings.resolveServers();
|
|
540
|
+
|
|
541
|
+
expect(result).toHaveLength(1);
|
|
542
|
+
expect(result[0].baseUrl).toBe("http://127.0.0.1:8080");
|
|
543
|
+
expect(result[0].providerId).toBe("custom-id");
|
|
544
|
+
expect(result[0].providerName).toBe(`Llama.cpp (Custom)`);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("should handle multiple URLs with partial id/name overrides", () => {
|
|
548
|
+
mockGetProjectSettings.mockReturnValue({
|
|
549
|
+
llamaSettings: {
|
|
550
|
+
servers: [{ url: "http://first:8080", id: "first-server" }],
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
process.env.LLAMA_SERVER_URL = "http://first:8080;http://second:9090";
|
|
554
|
+
|
|
555
|
+
const result = settings.resolveServers();
|
|
556
|
+
|
|
557
|
+
expect(result).toHaveLength(2);
|
|
558
|
+
expect(result[0].baseUrl).toBe("http://first:8080");
|
|
559
|
+
expect(result[0].providerId).toBe("first-server");
|
|
560
|
+
expect(result[1].baseUrl).toBe("http://second:9090");
|
|
561
|
+
expect(result[1].providerId).toBe(`${PROVIDER_PREFIX}=http://second:9090`);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("should prioritize env variable over global servers", async () => {
|
|
565
|
+
process.env.LLAMA_SERVER_URL = "http://env:8080";
|
|
566
|
+
mockGetGlobalSettings.mockReturnValue({
|
|
567
|
+
llamaSettings: {
|
|
568
|
+
servers: [{ url: "http://global:9090" }],
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const result = settings.resolveServers();
|
|
573
|
+
|
|
574
|
+
// env variable takes precedence via resolveUrls
|
|
575
|
+
expect(result).toHaveLength(1);
|
|
576
|
+
expect(result[0].baseUrl).toBe("http://env:8080");
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
describe("resolveTimeouts", () => {
|
|
581
|
+
afterEach(() => {
|
|
582
|
+
vi.resetModules();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("should return default timeouts when not configured", async () => {
|
|
586
|
+
const { settings } = await import("../src/managers/settings");
|
|
587
|
+
|
|
588
|
+
const result = settings.resolveTimeouts();
|
|
589
|
+
|
|
590
|
+
expect(result).toEqual({
|
|
591
|
+
pollingTimeout: POLLING_TIMEOUT,
|
|
592
|
+
serverTimeout: SERVER_TIMEOUT,
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("should use user-defined pollingTimeout", async () => {
|
|
597
|
+
mockSettingsManager.getProjectSettings.mockReturnValue({
|
|
598
|
+
llamaSettings: {
|
|
599
|
+
pollingTimeout: 120000,
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const { settings } = await import("../src/managers/settings");
|
|
604
|
+
|
|
605
|
+
const result = settings.resolveTimeouts();
|
|
606
|
+
|
|
607
|
+
expect(result.pollingTimeout).toBe(120000);
|
|
608
|
+
expect(result.serverTimeout).toBe(SERVER_TIMEOUT);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("should use user-defined serverTimeout", async () => {
|
|
612
|
+
mockSettingsManager.getProjectSettings.mockReturnValue({
|
|
613
|
+
llamaSettings: {
|
|
614
|
+
serverTimeout: 3000,
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const { settings } = await import("../src/managers/settings");
|
|
619
|
+
|
|
620
|
+
const result = settings.resolveTimeouts();
|
|
621
|
+
|
|
622
|
+
expect(result.pollingTimeout).toBe(POLLING_TIMEOUT);
|
|
623
|
+
expect(result.serverTimeout).toBe(3000);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("should use both user-defined timeouts", async () => {
|
|
627
|
+
mockSettingsManager.getProjectSettings.mockReturnValue({
|
|
628
|
+
llamaSettings: {
|
|
629
|
+
pollingTimeout: 90000,
|
|
630
|
+
serverTimeout: 2000,
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
const { settings } = await import("../src/managers/settings");
|
|
635
|
+
|
|
636
|
+
const result = settings.resolveTimeouts();
|
|
637
|
+
|
|
638
|
+
expect(result).toEqual({
|
|
639
|
+
pollingTimeout: 90000,
|
|
640
|
+
serverTimeout: 2000,
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
describe("Thinking config resolution", () => {
|
|
646
|
+
const mockGetAgentDir = vi.mocked(getAgentDir);
|
|
647
|
+
|
|
648
|
+
afterEach(() => {
|
|
649
|
+
vi.resetModules();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
beforeEach(() => {
|
|
653
|
+
vi.clearAllMocks();
|
|
654
|
+
mockGetAgentDir.mockReturnValue("/fake/agent/dir");
|
|
655
|
+
mockSettingsManager.getDefaultThinkingLevel.mockReturnValue("medium");
|
|
656
|
+
mockSettingsManager.getThinkingBudgets.mockReturnValue({});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("should return the default thinking level from SettingsManager", () => {
|
|
660
|
+
mockSettingsManager.getDefaultThinkingLevel.mockReturnValue("low");
|
|
661
|
+
|
|
662
|
+
const result = settings.resolveThinkingLevel();
|
|
663
|
+
|
|
664
|
+
expect(result).toEqual("low");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("should return thinking budgets merged with defaults", () => {
|
|
668
|
+
mockSettingsManager.getThinkingBudgets.mockReturnValue({
|
|
669
|
+
low: 4096,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const result = settings.resolveThinkingBudgets();
|
|
673
|
+
|
|
674
|
+
expect(result).toEqual(
|
|
675
|
+
expect.objectContaining({
|
|
676
|
+
off: 0,
|
|
677
|
+
minimal: 1024,
|
|
678
|
+
low: 4096,
|
|
679
|
+
medium: 8192,
|
|
680
|
+
high: 16384,
|
|
681
|
+
xhigh: 32768,
|
|
682
|
+
max: -1,
|
|
683
|
+
}),
|
|
684
|
+
);
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
describe("setLlamaSetting", () => {
|
|
689
|
+
const mockGetAgentDir = vi.mocked(getAgentDir);
|
|
690
|
+
const mockGetProjectSettings = vi.mocked(
|
|
691
|
+
mockSettingsManager.getProjectSettings,
|
|
692
|
+
);
|
|
693
|
+
const mockGetGlobalSettings = vi.mocked(
|
|
694
|
+
mockSettingsManager.getGlobalSettings,
|
|
695
|
+
);
|
|
696
|
+
const mockReload = vi.mocked(mockSettingsManager.reload);
|
|
697
|
+
const mockReadFile = vi.mocked(readFile);
|
|
698
|
+
const mockWriteFile = vi.mocked(writeFile);
|
|
699
|
+
const mockRename = vi.mocked(rename);
|
|
700
|
+
|
|
701
|
+
const SETTINGS_PATH = "/fake/agent/dir/settings.json";
|
|
702
|
+
|
|
703
|
+
beforeEach(() => {
|
|
704
|
+
vi.clearAllMocks();
|
|
705
|
+
mockGetAgentDir.mockReturnValue("/fake/agent/dir");
|
|
706
|
+
mockGetProjectSettings.mockReturnValue({});
|
|
707
|
+
mockGetGlobalSettings.mockReturnValue({});
|
|
708
|
+
mockReload.mockResolvedValue(undefined);
|
|
709
|
+
mockReadFile.mockResolvedValue("{}");
|
|
710
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
711
|
+
mockRename.mockResolvedValue(undefined);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("should write the merged llamaSettings key atomically and reload", async () => {
|
|
715
|
+
mockReadFile.mockResolvedValue(
|
|
716
|
+
JSON.stringify(
|
|
717
|
+
{ unrelated: true, llamaSettings: { reactToModelSelect: true } },
|
|
718
|
+
null,
|
|
719
|
+
2,
|
|
720
|
+
),
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
await settings.setLlamaSetting("sortBy", "desc");
|
|
724
|
+
|
|
725
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
|
726
|
+
const [tmpPath, written, encoding] = mockWriteFile.mock.calls[0];
|
|
727
|
+
expect(tmpPath).toBe(`${SETTINGS_PATH}.tmp`);
|
|
728
|
+
expect(encoding).toBe("utf-8");
|
|
729
|
+
const parsed = JSON.parse(written as string);
|
|
730
|
+
expect(parsed).toEqual({
|
|
731
|
+
unrelated: true,
|
|
732
|
+
llamaSettings: { reactToModelSelect: true, sortBy: "desc" },
|
|
733
|
+
});
|
|
734
|
+
expect(mockRename).toHaveBeenCalledWith(
|
|
735
|
+
`${SETTINGS_PATH}.tmp`,
|
|
736
|
+
SETTINGS_PATH,
|
|
737
|
+
);
|
|
738
|
+
expect(mockReload).toHaveBeenCalledTimes(1);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("should reflect the new value in resolvers immediately after the write", async () => {
|
|
742
|
+
mockSettingsManager.reload.mockImplementation(async () => {
|
|
743
|
+
mockGetGlobalSettings.mockReturnValue({
|
|
744
|
+
llamaSettings: { sortBy: "desc" },
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
await settings.setLlamaSetting("sortBy", "desc");
|
|
749
|
+
|
|
750
|
+
expect(settings.resolveSortBy()).toBe("desc");
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("should reject and skip reload when the write fails", async () => {
|
|
754
|
+
mockWriteFile.mockRejectedValue(new Error("ENOSPC: simulated"));
|
|
755
|
+
|
|
756
|
+
await expect(settings.setLlamaSetting("sortBy", "desc")).rejects.toThrow(
|
|
757
|
+
"ENOSPC",
|
|
758
|
+
);
|
|
759
|
+
expect(mockReload).not.toHaveBeenCalled();
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("should reject and leave the file untouched when the JSON is invalid", async () => {
|
|
763
|
+
mockReadFile.mockResolvedValue("{ broken");
|
|
764
|
+
|
|
765
|
+
await expect(settings.setLlamaSetting("sortBy", "desc")).rejects.toThrow(
|
|
766
|
+
/Cannot parse/,
|
|
767
|
+
);
|
|
768
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
769
|
+
expect(mockReload).not.toHaveBeenCalled();
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("should persist booleans and numbers with type fidelity", async () => {
|
|
773
|
+
await settings.setLlamaSetting("reactToModelSelect", false);
|
|
774
|
+
|
|
775
|
+
const [, firstWrite] = mockWriteFile.mock.calls[0];
|
|
776
|
+
expect(JSON.parse(firstWrite as string)).toEqual({
|
|
777
|
+
llamaSettings: { reactToModelSelect: false },
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
await settings.setLlamaSetting("pollingTimeout", 120000);
|
|
781
|
+
|
|
782
|
+
const [, secondWrite] = mockWriteFile.mock.calls[1];
|
|
783
|
+
expect(JSON.parse(secondWrite as string)).toEqual({
|
|
784
|
+
llamaSettings: { pollingTimeout: 120000 },
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("should still construct the manager without arguments", async () => {
|
|
789
|
+
const { LlamaSettingsManager } = await import("../src/managers/settings");
|
|
790
|
+
|
|
791
|
+
expect(() => new LlamaSettingsManager()).not.toThrow();
|
|
792
|
+
});
|
|
793
|
+
});
|