pi-llama-cpp 0.10.0 → 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 +66 -5
- package/package.json +3 -3
- package/src/api/client.ts +76 -32
- package/src/constants.ts +15 -0
- package/src/index.ts +3 -5
- package/src/interfaces/events.ts +14 -3
- package/src/interfaces/server.ts +20 -0
- package/src/interfaces/settings.ts +11 -2
- package/src/managers/command.ts +285 -43
- package/src/managers/events.ts +28 -10
- package/src/managers/server.ts +87 -16
- package/src/managers/settings.ts +78 -16
- package/src/models/baseModel.ts +0 -8
- package/src/server.ts +69 -28
- package/src/sse/client.ts +28 -16
- package/src/sse/manager.ts +22 -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 +117 -88
- package/tests/mocks.ts +145 -32
- package/tests/server.test.ts +37 -35
- package/tests/serverListEditor.test.ts +637 -0
- package/tests/serverManager.test.ts +282 -39
- package/tests/settings.test.ts +173 -16
- package/tests/settingsStore.test.ts +209 -0
- package/tests/sseManager.test.ts +88 -11
- package/src/interfaces/auth.ts +0 -6
- package/src/utils/cache.ts +0 -39
- package/src/utils/mutex.ts +0 -24
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { SettingsStore } from "../src/utils/settingsStore";
|
|
4
|
+
|
|
5
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
6
|
+
getAgentDir: vi.fn().mockReturnValue("/fake/agent/dir"),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const mockReadFile = vi.hoisted(() => vi.fn());
|
|
10
|
+
const mockWriteFile = vi.hoisted(() => vi.fn());
|
|
11
|
+
const mockRename = vi.hoisted(() => vi.fn());
|
|
12
|
+
|
|
13
|
+
vi.mock("node:fs/promises", () => ({
|
|
14
|
+
readFile: mockReadFile,
|
|
15
|
+
writeFile: mockWriteFile,
|
|
16
|
+
rename: mockRename,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const mockGetAgentDir = vi.mocked(getAgentDir);
|
|
20
|
+
|
|
21
|
+
const SETTINGS_PATH = "/fake/agent/dir/settings.json";
|
|
22
|
+
|
|
23
|
+
describe("SettingsStore.read", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
mockGetAgentDir.mockReturnValue("/fake/agent/dir");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should return {} when the file is missing (ENOENT)", async () => {
|
|
30
|
+
mockReadFile.mockRejectedValue(
|
|
31
|
+
Object.assign(new Error("nope"), { code: "ENOENT" }),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const store = new SettingsStore(SETTINGS_PATH);
|
|
35
|
+
|
|
36
|
+
await expect(store.read()).resolves.toEqual({});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should reject with a message containing the path on invalid JSON", async () => {
|
|
40
|
+
mockReadFile.mockResolvedValue("{ not valid json");
|
|
41
|
+
|
|
42
|
+
const store = new SettingsStore(SETTINGS_PATH);
|
|
43
|
+
|
|
44
|
+
await expect(store.read()).rejects.toThrow(/Cannot parse.*settings\.json/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should propagate non-ENOENT IO errors", async () => {
|
|
48
|
+
mockReadFile.mockRejectedValue(
|
|
49
|
+
Object.assign(new Error("permission denied"), { code: "EACCES" }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const store = new SettingsStore(SETTINGS_PATH);
|
|
53
|
+
|
|
54
|
+
await expect(store.read()).rejects.toMatchObject({ code: "EACCES" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should default the path to <agentDir>/settings.json", async () => {
|
|
58
|
+
mockReadFile.mockResolvedValue('{"a":1}');
|
|
59
|
+
|
|
60
|
+
const store = new SettingsStore();
|
|
61
|
+
await store.read();
|
|
62
|
+
|
|
63
|
+
expect(mockReadFile).toHaveBeenCalledWith(SETTINGS_PATH, "utf-8");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("SettingsStore.updateKey — file content", () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.clearAllMocks();
|
|
70
|
+
mockGetAgentDir.mockReturnValue("/fake/agent/dir");
|
|
71
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
72
|
+
mockRename.mockResolvedValue(undefined);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should create the file with only the target key when the file was missing", async () => {
|
|
76
|
+
mockReadFile.mockRejectedValue(
|
|
77
|
+
Object.assign(new Error("nope"), { code: "ENOENT" }),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const store = new SettingsStore(SETTINGS_PATH);
|
|
81
|
+
await store.updateKey("llamaSettings", () => ({ sortBy: "asc" }));
|
|
82
|
+
|
|
83
|
+
const [, written] = mockWriteFile.mock.calls[0];
|
|
84
|
+
expect(JSON.parse(written as string)).toEqual({
|
|
85
|
+
llamaSettings: { sortBy: "asc" },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should preserve unrelated keys, their order, and merge into an existing target key", async () => {
|
|
90
|
+
mockReadFile.mockResolvedValue(
|
|
91
|
+
'{"aaa":1,"llamaSettings":{"sortBy":"asc"},"zzz":9}',
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const store = new SettingsStore(SETTINGS_PATH);
|
|
95
|
+
await store.updateKey("llamaSettings", (current) => ({
|
|
96
|
+
...(current as Record<string, unknown>),
|
|
97
|
+
sortBy: "desc",
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
const [, written] = mockWriteFile.mock.calls[0];
|
|
101
|
+
const parsed = JSON.parse(written as string);
|
|
102
|
+
expect(Object.keys(parsed)).toEqual(["aaa", "llamaSettings", "zzz"]);
|
|
103
|
+
expect(parsed.llamaSettings).toEqual({ sortBy: "desc" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should start from {} when the target key holds a non-object (null)", async () => {
|
|
107
|
+
mockReadFile.mockResolvedValue('{"llamaSettings":null}');
|
|
108
|
+
|
|
109
|
+
const store = new SettingsStore(SETTINGS_PATH);
|
|
110
|
+
await store.updateKey("llamaSettings", (current) => ({
|
|
111
|
+
...(typeof current === "object" && current !== null ? current : {}),
|
|
112
|
+
autoloadOnMessage: true,
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
const [, written] = mockWriteFile.mock.calls[0];
|
|
116
|
+
expect(JSON.parse(written as string)).toEqual({
|
|
117
|
+
llamaSettings: { autoloadOnMessage: true },
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("SettingsStore.updateKey — write path", () => {
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
vi.clearAllMocks();
|
|
125
|
+
mockGetAgentDir.mockReturnValue("/fake/agent/dir");
|
|
126
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
127
|
+
mockRename.mockResolvedValue(undefined);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should write to <path>.tmp with 2-space indent, then rename onto the real path", async () => {
|
|
131
|
+
mockReadFile.mockResolvedValue('{"llamaSettings":{"sortBy":"asc"}}');
|
|
132
|
+
|
|
133
|
+
const store = new SettingsStore(SETTINGS_PATH);
|
|
134
|
+
await store.updateKey("llamaSettings", (current) => ({
|
|
135
|
+
...(current as Record<string, unknown>),
|
|
136
|
+
sortBy: "desc",
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
|
140
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
141
|
+
`${SETTINGS_PATH}.tmp`,
|
|
142
|
+
expect.stringContaining('\n "llamaSettings"'),
|
|
143
|
+
"utf-8",
|
|
144
|
+
);
|
|
145
|
+
expect(mockRename).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(mockRename).toHaveBeenCalledWith(
|
|
147
|
+
`${SETTINGS_PATH}.tmp`,
|
|
148
|
+
SETTINGS_PATH,
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should apply queued concurrent writes in submission order (last write wins)", async () => {
|
|
153
|
+
// Simulate a real file: reads return the last written content.
|
|
154
|
+
let contents = "{}";
|
|
155
|
+
mockReadFile.mockImplementation(async () => contents);
|
|
156
|
+
mockWriteFile.mockImplementation(async (_path: unknown, data: string) => {
|
|
157
|
+
contents = data;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const store = new SettingsStore(SETTINGS_PATH);
|
|
161
|
+
const first = store.updateKey("llamaSettings", (current) => ({
|
|
162
|
+
...(current as Record<string, unknown>),
|
|
163
|
+
first: 1,
|
|
164
|
+
}));
|
|
165
|
+
const second = store.updateKey("llamaSettings", (current) => ({
|
|
166
|
+
...(current as Record<string, unknown>),
|
|
167
|
+
second: 2,
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
await first;
|
|
171
|
+
await second;
|
|
172
|
+
|
|
173
|
+
// Without the queue both writes would read the same "{}" and the final
|
|
174
|
+
// content would only hold the last caller's key.
|
|
175
|
+
expect(mockReadFile).toHaveBeenCalledTimes(2);
|
|
176
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(2);
|
|
177
|
+
const [, firstWrite] = mockWriteFile.mock.calls[0];
|
|
178
|
+
const [, secondWrite] = mockWriteFile.mock.calls[1];
|
|
179
|
+
expect(JSON.parse(firstWrite as string)).toEqual({
|
|
180
|
+
llamaSettings: { first: 1 },
|
|
181
|
+
});
|
|
182
|
+
expect(JSON.parse(secondWrite as string)).toEqual({
|
|
183
|
+
llamaSettings: { first: 1, second: 2 },
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should reject a failing write but let the next queued update still run", async () => {
|
|
188
|
+
mockReadFile.mockResolvedValue('{"llamaSettings":{}}');
|
|
189
|
+
mockWriteFile.mockRejectedValueOnce(new Error("ENOSPC: simulated"));
|
|
190
|
+
|
|
191
|
+
const store = new SettingsStore(SETTINGS_PATH);
|
|
192
|
+
const failing = store.updateKey("llamaSettings", (current) => ({
|
|
193
|
+
...(current as Record<string, unknown>),
|
|
194
|
+
a: 1,
|
|
195
|
+
}));
|
|
196
|
+
const following = store.updateKey("llamaSettings", (current) => ({
|
|
197
|
+
...(current as Record<string, unknown>),
|
|
198
|
+
b: 2,
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
await expect(failing).rejects.toThrow("ENOSPC");
|
|
202
|
+
await expect(following).resolves.toBeUndefined();
|
|
203
|
+
|
|
204
|
+
const [, lastWrite] = mockWriteFile.mock.calls[1];
|
|
205
|
+
expect(JSON.parse(lastWrite as string)).toEqual({
|
|
206
|
+
llamaSettings: { b: 2 },
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
package/tests/sseManager.test.ts
CHANGED
|
@@ -1,21 +1,98 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { SERVER_TIMEOUT } from "../src/constants";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
2
|
import { SSEManager } from "../src/sse/manager";
|
|
3
|
+
import { createMockServer } from "./mocks";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Builds an SSEManager bound to a stub server, mirroring how
|
|
7
|
+
* `Server.initialize()` wires it up (timeouts are read through the server).
|
|
8
|
+
*/
|
|
9
|
+
const createManager = (
|
|
10
|
+
pollingTimeout = 60000,
|
|
11
|
+
serverTimeout = 1000,
|
|
12
|
+
): SSEManager =>
|
|
13
|
+
new SSEManager(
|
|
14
|
+
createMockServer({
|
|
15
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
16
|
+
pollingTimeout,
|
|
17
|
+
serverTimeout,
|
|
18
|
+
}),
|
|
19
|
+
"",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
describe("SSEManager timeouts", () => {
|
|
23
|
+
it("should expose the timeouts of its server", () => {
|
|
24
|
+
const manager = createManager(5678, 1234);
|
|
25
|
+
|
|
26
|
+
expect(manager.pollingTimeout).toBe(5678);
|
|
27
|
+
expect(manager.serverTimeout).toBe(1234);
|
|
28
|
+
});
|
|
7
29
|
});
|
|
8
30
|
|
|
9
|
-
describe("SSEManager
|
|
10
|
-
it("should
|
|
11
|
-
|
|
31
|
+
describe("SSEManager subscribeToStatus", () => {
|
|
32
|
+
it("should reject after the server's pollingTimeout, not the constant", async () => {
|
|
33
|
+
vi.useFakeTimers();
|
|
34
|
+
vi.stubGlobal(
|
|
35
|
+
"EventSource",
|
|
36
|
+
class StubEventSource {
|
|
37
|
+
onopen = null;
|
|
38
|
+
onerror = null;
|
|
39
|
+
onmessage = null;
|
|
40
|
+
close() {}
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const manager = createManager(5000);
|
|
46
|
+
const promise = manager.subscribeToStatus("test-model");
|
|
47
|
+
const expectation = expect(promise).rejects.toThrow(
|
|
48
|
+
"SSE status timeout for model: test-model",
|
|
49
|
+
);
|
|
12
50
|
|
|
13
|
-
|
|
51
|
+
// If the timer still used the fixed 60 s constant, advancing 5 s
|
|
52
|
+
// would not trigger the rejection.
|
|
53
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
54
|
+
await expectation;
|
|
55
|
+
} finally {
|
|
56
|
+
vi.unstubAllGlobals();
|
|
57
|
+
vi.useRealTimers();
|
|
58
|
+
}
|
|
14
59
|
});
|
|
15
60
|
|
|
16
|
-
it("should
|
|
17
|
-
|
|
61
|
+
it("should resolve when a terminal status arrives before the timeout", async () => {
|
|
62
|
+
vi.useFakeTimers();
|
|
63
|
+
vi.stubGlobal(
|
|
64
|
+
"EventSource",
|
|
65
|
+
class StubEventSource {
|
|
66
|
+
onopen = null;
|
|
67
|
+
onerror = null;
|
|
68
|
+
onmessage = null;
|
|
69
|
+
close() {}
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const manager = createManager(5000);
|
|
75
|
+
const promise = manager.subscribeToStatus("test-model");
|
|
76
|
+
const expectation = expect(promise).resolves.toMatchObject({
|
|
77
|
+
status: "loaded",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
manager.subscribeToProgress("test-model", () => {});
|
|
81
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
82
|
+
|
|
83
|
+
// Emit a terminal status through the shared client's dispatch path
|
|
84
|
+
const client = (manager as any).sseClient;
|
|
85
|
+
const handler = client.subscribers.get("test-model");
|
|
86
|
+
handler({
|
|
87
|
+
event: "status_change",
|
|
88
|
+
model: "test-model",
|
|
89
|
+
data: { status: "loaded" },
|
|
90
|
+
});
|
|
18
91
|
|
|
19
|
-
|
|
92
|
+
await expectation;
|
|
93
|
+
} finally {
|
|
94
|
+
vi.unstubAllGlobals();
|
|
95
|
+
vi.useRealTimers();
|
|
96
|
+
}
|
|
20
97
|
});
|
|
21
98
|
});
|
package/src/interfaces/auth.ts
DELETED
package/src/utils/cache.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Generic TTL cache.
|
|
3
|
-
* Entries expire after `ttl` milliseconds from the time they were set.
|
|
4
|
-
*/
|
|
5
|
-
export class Cache {
|
|
6
|
-
private entries = new Map<string, { data: unknown; timestamp: number }>();
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @param ttl Time-to-live in milliseconds
|
|
10
|
-
*/
|
|
11
|
-
constructor(private readonly ttl: number) {}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Gets a cached value by key. Returns `undefined` if missing or expired.
|
|
15
|
-
*/
|
|
16
|
-
get<T>(key: string): T | undefined {
|
|
17
|
-
const entry = this.entries.get(key);
|
|
18
|
-
if (!entry) return undefined;
|
|
19
|
-
if (Date.now() - entry.timestamp > this.ttl) {
|
|
20
|
-
this.entries.delete(key);
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
return entry.data as T;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Stores a value in the cache with the current timestamp.
|
|
28
|
-
*/
|
|
29
|
-
set(key: string, data: unknown): void {
|
|
30
|
-
this.entries.set(key, { data, timestamp: Date.now() });
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Clears all cached entries.
|
|
35
|
-
*/
|
|
36
|
-
clear(): void {
|
|
37
|
-
this.entries.clear();
|
|
38
|
-
}
|
|
39
|
-
}
|
package/src/utils/mutex.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ensures only one in-flight operation exists per key.
|
|
3
|
-
* Concurrent callers for the same key share the same promise.
|
|
4
|
-
*/
|
|
5
|
-
export class Mutex {
|
|
6
|
-
private promises = new Map<string, Promise<unknown>>();
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Runs `fn` for the given key, or returns an existing in-flight promise.
|
|
10
|
-
* Concurrent callers for the same key share the same promise.
|
|
11
|
-
*/
|
|
12
|
-
getOrCreate<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
13
|
-
const existing = this.promises.get(key);
|
|
14
|
-
if (existing) {
|
|
15
|
-
return existing as Promise<T>;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const promise = fn().finally(() => {
|
|
19
|
-
this.promises.delete(key);
|
|
20
|
-
});
|
|
21
|
-
this.promises.set(key, promise);
|
|
22
|
-
return promise;
|
|
23
|
-
}
|
|
24
|
-
}
|