@mingxy/cerebro 1.20.5 → 1.20.6
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/package.json +7 -3
- package/src/client.test.ts +373 -0
- package/src/config.test.ts +405 -0
- package/src/hooks-tier1.test.ts +220 -0
- package/src/hooks-tier2.test.ts +275 -0
- package/src/hooks-tier3.test.ts +461 -0
- package/src/hooks.ts +48 -12
- package/src/index.test.ts +190 -0
- package/src/index.ts +12 -2
- package/src/keywords.test.ts +283 -0
- package/src/logger.test.ts +640 -0
- package/src/privacy.test.ts +128 -0
- package/src/tags.test.ts +86 -0
- package/src/tools.test.ts +508 -0
- package/src/tools.ts +34 -0
- package/src/updater.test.ts +380 -0
- package/src/web-server.test.ts +740 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { loadPluginConfig, resolveAgentPolicy, DEFAULTS } from "./config.js";
|
|
3
|
+
import type { CerebroPluginConfig } from "./config.js";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
|
|
6
|
+
vi.mock("node:fs", () => ({
|
|
7
|
+
readFileSync: vi.fn(() => {
|
|
8
|
+
throw new Error("no config file");
|
|
9
|
+
}),
|
|
10
|
+
appendFileSync: vi.fn(),
|
|
11
|
+
mkdirSync: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("node:os", () => ({
|
|
15
|
+
homedir: vi.fn(() => "/mock/home"),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe("DEFAULTS", () => {
|
|
19
|
+
it("has connection defaults", () => {
|
|
20
|
+
expect(DEFAULTS.connection).toBeDefined();
|
|
21
|
+
expect(DEFAULTS.connection.apiUrl).toBe("https://www.mengxy.cc");
|
|
22
|
+
expect(DEFAULTS.connection.apiKey).toBe("");
|
|
23
|
+
expect(DEFAULTS.connection.requestTimeoutMs).toBe(15000);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("has content defaults", () => {
|
|
27
|
+
expect(DEFAULTS.content).toBeDefined();
|
|
28
|
+
expect(DEFAULTS.content.maxQueryLength).toBe(200);
|
|
29
|
+
expect(DEFAULTS.content.maxContentChars).toBe(30000);
|
|
30
|
+
expect(DEFAULTS.content.maxContentLength).toBe(3000);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("has injection defaults", () => {
|
|
34
|
+
expect(DEFAULTS.injection).toBeDefined();
|
|
35
|
+
expect(DEFAULTS.injection.recentCount).toBe(5);
|
|
36
|
+
expect(DEFAULTS.injection.searchCount).toBe(10);
|
|
37
|
+
expect(DEFAULTS.injection.recentTruncateChars).toBe(0);
|
|
38
|
+
expect(DEFAULTS.injection.searchTruncateChars).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("has ingest defaults", () => {
|
|
42
|
+
expect(DEFAULTS.ingest).toBeDefined();
|
|
43
|
+
expect(DEFAULTS.ingest.autoCaptureThreshold).toBe(5);
|
|
44
|
+
expect(DEFAULTS.ingest.ingestMode).toBe("smart");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("has logging defaults", () => {
|
|
48
|
+
expect(DEFAULTS.logging).toBeDefined();
|
|
49
|
+
expect(DEFAULTS.logging.logEnabled).toBe(true);
|
|
50
|
+
expect(DEFAULTS.logging.logLevel).toBe("INFO");
|
|
51
|
+
expect(DEFAULTS.logging.logDir).toContain(".config/cerebro");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("has ui defaults", () => {
|
|
55
|
+
expect(DEFAULTS.ui).toBeDefined();
|
|
56
|
+
expect(DEFAULTS.ui.toastDelayMs).toBe(7000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("has web defaults", () => {
|
|
60
|
+
expect(DEFAULTS.web).toBeDefined();
|
|
61
|
+
expect(DEFAULTS.web!.enabled).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("has autoUpdate default as false", () => {
|
|
65
|
+
expect(DEFAULTS.autoUpdate).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── loadPluginConfig ───────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe("loadPluginConfig", () => {
|
|
72
|
+
const mockReadFileSync = vi.mocked(fs.readFileSync);
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
vi.unstubAllEnvs();
|
|
76
|
+
mockReadFileSync.mockImplementation(() => {
|
|
77
|
+
throw new Error("no config file");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.unstubAllEnvs();
|
|
83
|
+
mockReadFileSync.mockRestore();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns defaults when no config file and no overrides", () => {
|
|
87
|
+
const config = loadPluginConfig();
|
|
88
|
+
expect(config.connection.apiUrl).toBe("https://www.mengxy.cc");
|
|
89
|
+
expect(config.connection.apiKey).toBe("");
|
|
90
|
+
expect(config.connection.requestTimeoutMs).toBe(15000);
|
|
91
|
+
expect(config.ingest.ingestMode).toBe("smart");
|
|
92
|
+
expect(config.ingest.autoCaptureThreshold).toBe(5);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("deep merges overrides correctly", () => {
|
|
96
|
+
const config = loadPluginConfig({
|
|
97
|
+
connection: { apiUrl: "http://custom:3000", apiKey: "key123" },
|
|
98
|
+
ingest: { ingestMode: "raw" },
|
|
99
|
+
});
|
|
100
|
+
expect(config.connection.apiUrl).toBe("http://custom:3000");
|
|
101
|
+
expect(config.connection.apiKey).toBe("key123");
|
|
102
|
+
// requestTimeoutMs should keep default since not overridden
|
|
103
|
+
expect(config.connection.requestTimeoutMs).toBe(15000);
|
|
104
|
+
expect(config.ingest.ingestMode).toBe("raw");
|
|
105
|
+
// autoCaptureThreshold should keep default
|
|
106
|
+
expect(config.ingest.autoCaptureThreshold).toBe(5);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("loads nested config from file", () => {
|
|
110
|
+
mockReadFileSync.mockReturnValue(
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
connection: { apiUrl: "http://file-url:9999" },
|
|
113
|
+
ingest: { autoCaptureThreshold: 10 },
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
const config = loadPluginConfig();
|
|
117
|
+
expect(config.connection.apiUrl).toBe("http://file-url:9999");
|
|
118
|
+
expect(config.ingest.autoCaptureThreshold).toBe(10);
|
|
119
|
+
// Non-overridden fields keep defaults
|
|
120
|
+
expect(config.connection.requestTimeoutMs).toBe(15000);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("migrates flat config (apiUrl at top level)", () => {
|
|
124
|
+
mockReadFileSync.mockReturnValue(
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
apiUrl: "http://flat-url:8080",
|
|
127
|
+
apiKey: "flat-key",
|
|
128
|
+
requestTimeoutMs: 5000,
|
|
129
|
+
maxQueryLength: 500,
|
|
130
|
+
autoCaptureThreshold: 3,
|
|
131
|
+
ingestMode: "raw",
|
|
132
|
+
toastDelayMs: 3000,
|
|
133
|
+
logEnabled: false,
|
|
134
|
+
logLevel: "DEBUG",
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
const config = loadPluginConfig();
|
|
138
|
+
expect(config.connection.apiUrl).toBe("http://flat-url:8080");
|
|
139
|
+
expect(config.connection.apiKey).toBe("flat-key");
|
|
140
|
+
expect(config.connection.requestTimeoutMs).toBe(5000);
|
|
141
|
+
expect(config.content.maxQueryLength).toBe(500);
|
|
142
|
+
expect(config.ingest.autoCaptureThreshold).toBe(3);
|
|
143
|
+
expect(config.ingest.ingestMode).toBe("raw");
|
|
144
|
+
expect(config.ui.toastDelayMs).toBe(3000);
|
|
145
|
+
expect(config.logging.logEnabled).toBe(false);
|
|
146
|
+
expect(config.logging.logLevel).toBe("DEBUG");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("does NOT migrate when connection field exists", () => {
|
|
150
|
+
mockReadFileSync.mockReturnValue(
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
apiUrl: "http://should-not-migrate",
|
|
153
|
+
connection: { apiUrl: "http://nested-url" },
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
const config = loadPluginConfig();
|
|
157
|
+
// Should use nested connection.apiUrl, not top-level apiUrl
|
|
158
|
+
expect(config.connection.apiUrl).toBe("http://nested-url");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── Environment variable overrides ──
|
|
162
|
+
|
|
163
|
+
it("OMEM_API_URL overrides apiUrl", () => {
|
|
164
|
+
vi.stubEnv("OMEM_API_URL", "http://env-url:7777");
|
|
165
|
+
const config = loadPluginConfig();
|
|
166
|
+
expect(config.connection.apiUrl).toBe("http://env-url:7777");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("OMEM_API_KEY overrides apiKey", () => {
|
|
170
|
+
vi.stubEnv("OMEM_API_KEY", "env-api-key-123");
|
|
171
|
+
const config = loadPluginConfig();
|
|
172
|
+
expect(config.connection.apiKey).toBe("env-api-key-123");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("OMEM_REQUEST_TIMEOUT_MS overrides timeout", () => {
|
|
176
|
+
vi.stubEnv("OMEM_REQUEST_TIMEOUT_MS", "30000");
|
|
177
|
+
const config = loadPluginConfig();
|
|
178
|
+
expect(config.connection.requestTimeoutMs).toBe(30000);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("OMEM_REQUEST_TIMEOUT_MS falls back to default on invalid value", () => {
|
|
182
|
+
vi.stubEnv("OMEM_REQUEST_TIMEOUT_MS", "not-a-number");
|
|
183
|
+
const config = loadPluginConfig();
|
|
184
|
+
expect(config.connection.requestTimeoutMs).toBe(15000);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("OMEM_AUTO_CAPTURE_THRESHOLD overrides threshold", () => {
|
|
188
|
+
vi.stubEnv("OMEM_AUTO_CAPTURE_THRESHOLD", "15");
|
|
189
|
+
const config = loadPluginConfig();
|
|
190
|
+
expect(config.ingest.autoCaptureThreshold).toBe(15);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("OMEM_AUTO_CAPTURE_THRESHOLD falls back to default on invalid", () => {
|
|
194
|
+
vi.stubEnv("OMEM_AUTO_CAPTURE_THRESHOLD", "abc");
|
|
195
|
+
const config = loadPluginConfig();
|
|
196
|
+
expect(config.ingest.autoCaptureThreshold).toBe(5);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("OMEM_INGEST_MODE=raw overrides ingestMode", () => {
|
|
200
|
+
vi.stubEnv("OMEM_INGEST_MODE", "raw");
|
|
201
|
+
const config = loadPluginConfig();
|
|
202
|
+
expect(config.ingest.ingestMode).toBe("raw");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("OMEM_INGEST_MODE=smart overrides ingestMode", () => {
|
|
206
|
+
vi.stubEnv("OMEM_INGEST_MODE", "smart");
|
|
207
|
+
const config = loadPluginConfig();
|
|
208
|
+
expect(config.ingest.ingestMode).toBe("smart");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("OMEM_INGEST_MODE with invalid value is ignored", () => {
|
|
212
|
+
vi.stubEnv("OMEM_INGEST_MODE", "invalid");
|
|
213
|
+
const config = loadPluginConfig();
|
|
214
|
+
expect(config.ingest.ingestMode).toBe("smart"); // default
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("OMEM_WEB_ENABLED=false disables web", () => {
|
|
218
|
+
vi.stubEnv("OMEM_WEB_ENABLED", "false");
|
|
219
|
+
const config = loadPluginConfig();
|
|
220
|
+
expect(config.web!.enabled).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("OMEM_WEB_ENABLED=0 disables web", () => {
|
|
224
|
+
vi.stubEnv("OMEM_WEB_ENABLED", "0");
|
|
225
|
+
const config = loadPluginConfig();
|
|
226
|
+
expect(config.web!.enabled).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("OMEM_WEB_ENABLED=true does not disable web", () => {
|
|
230
|
+
vi.stubEnv("OMEM_WEB_ENABLED", "true");
|
|
231
|
+
const config = loadPluginConfig();
|
|
232
|
+
expect(config.web!.enabled).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("OMEM_LOCAL_PORT sets web port", () => {
|
|
236
|
+
vi.stubEnv("OMEM_LOCAL_PORT", "3456");
|
|
237
|
+
const config = loadPluginConfig();
|
|
238
|
+
expect(config.web!.port).toBe(3456);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("OMEM_LOCAL_PORT with invalid value falls back to default port", () => {
|
|
242
|
+
vi.stubEnv("OMEM_LOCAL_PORT", "not-a-number");
|
|
243
|
+
const config = loadPluginConfig();
|
|
244
|
+
// DEFAULTS.web!.port is undefined, so fallback is undefined
|
|
245
|
+
expect(config.web!.port).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("env vars have highest priority over file config and overrides", () => {
|
|
249
|
+
mockReadFileSync.mockReturnValue(
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
connection: { apiUrl: "http://file-url", apiKey: "file-key" },
|
|
252
|
+
})
|
|
253
|
+
);
|
|
254
|
+
vi.stubEnv("OMEM_API_URL", "http://env-url");
|
|
255
|
+
vi.stubEnv("OMEM_API_KEY", "env-key");
|
|
256
|
+
const config = loadPluginConfig({
|
|
257
|
+
connection: { apiUrl: "http://override-url" },
|
|
258
|
+
});
|
|
259
|
+
expect(config.connection.apiUrl).toBe("http://env-url");
|
|
260
|
+
expect(config.connection.apiKey).toBe("env-key");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ── ~ expansion in logDir ──
|
|
264
|
+
|
|
265
|
+
it("expands ~ in logDir to homedir", () => {
|
|
266
|
+
mockReadFileSync.mockReturnValue(
|
|
267
|
+
JSON.stringify({
|
|
268
|
+
logging: { logDir: "~/custom-logs" },
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
const config = loadPluginConfig();
|
|
272
|
+
expect(config.logging.logDir).toBe("/mock/home/custom-logs");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("does not expand logDir without ~ prefix", () => {
|
|
276
|
+
mockReadFileSync.mockReturnValue(
|
|
277
|
+
JSON.stringify({
|
|
278
|
+
logging: { logDir: "/absolute/path/logs" },
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
const config = loadPluginConfig();
|
|
282
|
+
expect(config.logging.logDir).toBe("/absolute/path/logs");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ── Edge cases ──
|
|
286
|
+
|
|
287
|
+
it("handles malformed JSON in config file gracefully", () => {
|
|
288
|
+
mockReadFileSync.mockReturnValue("{ invalid json");
|
|
289
|
+
const config = loadPluginConfig();
|
|
290
|
+
// Should fall back to defaults
|
|
291
|
+
expect(config.connection.apiUrl).toBe("https://www.mengxy.cc");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("handles empty overrides object", () => {
|
|
295
|
+
const config = loadPluginConfig({});
|
|
296
|
+
expect(config.connection.apiUrl).toBe("https://www.mengxy.cc");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("preserves overrides not present in env vars", () => {
|
|
300
|
+
const config = loadPluginConfig({
|
|
301
|
+
content: { maxQueryLength: 999, maxContentChars: 9999, maxContentLength: 999 },
|
|
302
|
+
});
|
|
303
|
+
expect(config.content.maxQueryLength).toBe(999);
|
|
304
|
+
expect(config.content.maxContentChars).toBe(9999);
|
|
305
|
+
expect(config.content.maxContentLength).toBe(999);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("applies agentMemoryPolicy from overrides", () => {
|
|
309
|
+
const config = loadPluginConfig({
|
|
310
|
+
agentMemoryPolicy: { oracle: "readonly", builder: "none" },
|
|
311
|
+
});
|
|
312
|
+
expect(config.agentMemoryPolicy).toEqual({ oracle: "readonly", builder: "none" });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("applies defaultPolicy from overrides", () => {
|
|
316
|
+
const config = loadPluginConfig({
|
|
317
|
+
defaultPolicy: "readonly",
|
|
318
|
+
});
|
|
319
|
+
expect(config.defaultPolicy).toBe("readonly");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("applies autoUpdate from overrides", () => {
|
|
323
|
+
const config = loadPluginConfig({ autoUpdate: true });
|
|
324
|
+
expect(config.autoUpdate).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("returns independent copies (no shared mutation)", () => {
|
|
328
|
+
const config1 = loadPluginConfig();
|
|
329
|
+
const config2 = loadPluginConfig();
|
|
330
|
+
config1.connection.apiUrl = "mutated";
|
|
331
|
+
expect(config2.connection.apiUrl).toBe("https://www.mengxy.cc");
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ── resolveAgentPolicy ─────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
describe("resolveAgentPolicy", () => {
|
|
338
|
+
it("returns exact match from agentMemoryPolicy", () => {
|
|
339
|
+
const config = {
|
|
340
|
+
agentMemoryPolicy: { oracle: "readonly" as const, builder: "none" as const },
|
|
341
|
+
};
|
|
342
|
+
expect(resolveAgentPolicy("oracle", config)).toBe("readonly");
|
|
343
|
+
expect(resolveAgentPolicy("builder", config)).toBe("none");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("uses prefix matching (case-insensitive) when no exact match", () => {
|
|
347
|
+
const config = {
|
|
348
|
+
agentMemoryPolicy: { build: "readwrite" as const },
|
|
349
|
+
};
|
|
350
|
+
// "builder" starts with "build"
|
|
351
|
+
expect(resolveAgentPolicy("builder", config)).toBe("readwrite");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("matches agentName prefix against policy key (case-insensitive)", () => {
|
|
355
|
+
const config = {
|
|
356
|
+
agentMemoryPolicy: { Build: "none" as const },
|
|
357
|
+
};
|
|
358
|
+
// "build" starts with "Build" (case-insensitive)
|
|
359
|
+
expect(resolveAgentPolicy("build", config)).toBe("none");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("falls back to defaultPolicy when no policy match", () => {
|
|
363
|
+
const config = {
|
|
364
|
+
agentMemoryPolicy: { oracle: "readonly" as const },
|
|
365
|
+
defaultPolicy: "readonly" as const,
|
|
366
|
+
};
|
|
367
|
+
expect(resolveAgentPolicy("unknown-agent", config)).toBe("readonly");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("falls back to 'readwrite' when no policy and no defaultPolicy", () => {
|
|
371
|
+
const config = {};
|
|
372
|
+
expect(resolveAgentPolicy("unknown-agent", config)).toBe("readwrite");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("falls back to 'readwrite' when agentMemoryPolicy is undefined", () => {
|
|
376
|
+
const config = { defaultPolicy: undefined };
|
|
377
|
+
expect(resolveAgentPolicy("any-agent", config)).toBe("readwrite");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("handles empty agentMemoryPolicy", () => {
|
|
381
|
+
const config = {
|
|
382
|
+
agentMemoryPolicy: {},
|
|
383
|
+
defaultPolicy: "none" as const,
|
|
384
|
+
};
|
|
385
|
+
expect(resolveAgentPolicy("any-agent", config)).toBe("none");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("exact match takes priority over prefix match", () => {
|
|
389
|
+
const config = {
|
|
390
|
+
agentMemoryPolicy: {
|
|
391
|
+
build: "none" as const,
|
|
392
|
+
builder: "readwrite" as const,
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
expect(resolveAgentPolicy("builder", config)).toBe("readwrite");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("is case-insensitive for exact matching", () => {
|
|
399
|
+
const config = {
|
|
400
|
+
agentMemoryPolicy: { Oracle: "readonly" as const },
|
|
401
|
+
};
|
|
402
|
+
expect(resolveAgentPolicy("oracle", config)).toBe("readonly");
|
|
403
|
+
expect(resolveAgentPolicy("ORACLE", config)).toBe("readonly");
|
|
404
|
+
});
|
|
405
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { showToast, createToast, createCerebroCompactionPrompt } from "./hooks.js";
|
|
3
|
+
import type { SearchResult } from "./client.js";
|
|
4
|
+
|
|
5
|
+
describe("showToast", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("calls tui.showToast with correct args after delay", () => {
|
|
15
|
+
const tui = { showToast: vi.fn() };
|
|
16
|
+
showToast(tui, "Title", "Hello", "info", 100);
|
|
17
|
+
|
|
18
|
+
// Not called immediately
|
|
19
|
+
expect(tui.showToast).not.toHaveBeenCalled();
|
|
20
|
+
|
|
21
|
+
// Called after delay
|
|
22
|
+
vi.advanceTimersByTime(100);
|
|
23
|
+
expect(tui.showToast).toHaveBeenCalledWith({
|
|
24
|
+
body: { title: "Title", message: "Hello", variant: "info", duration: 5000 },
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("uses default delay when delayMs is not provided", () => {
|
|
29
|
+
const tui = { showToast: vi.fn() };
|
|
30
|
+
showToast(tui, "Title", "Hello");
|
|
31
|
+
|
|
32
|
+
// DEFAULTS.ui.toastDelayMs = 7000
|
|
33
|
+
vi.advanceTimersByTime(6999);
|
|
34
|
+
expect(tui.showToast).not.toHaveBeenCalled();
|
|
35
|
+
|
|
36
|
+
vi.advanceTimersByTime(1);
|
|
37
|
+
expect(tui.showToast).toHaveBeenCalledWith({
|
|
38
|
+
body: { title: "Title", message: "Hello", variant: "info", duration: 5000 },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does nothing when tui is null", () => {
|
|
43
|
+
showToast(null, "Title", "Hello", "info", 100);
|
|
44
|
+
vi.advanceTimersByTime(200);
|
|
45
|
+
// No error thrown, no crash
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does nothing when tui is undefined", () => {
|
|
49
|
+
showToast(undefined as any, "Title", "Hello", "info", 100);
|
|
50
|
+
vi.advanceTimersByTime(200);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("catches errors from tui.showToast gracefully", () => {
|
|
54
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
55
|
+
const tui = {
|
|
56
|
+
showToast: vi.fn(() => {
|
|
57
|
+
throw new Error("toast boom");
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
showToast(tui, "Title", "Hello", "info", 50);
|
|
61
|
+
vi.advanceTimersByTime(50);
|
|
62
|
+
|
|
63
|
+
// Should not throw, just log
|
|
64
|
+
expect(tui.showToast).toHaveBeenCalled();
|
|
65
|
+
errorSpy.mockRestore();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("passes custom variant to tui.showToast", () => {
|
|
69
|
+
const tui = { showToast: vi.fn() };
|
|
70
|
+
showToast(tui, "Warn", "Something", "warning", 0);
|
|
71
|
+
vi.advanceTimersByTime(0);
|
|
72
|
+
expect(tui.showToast).toHaveBeenCalledWith({
|
|
73
|
+
body: { title: "Warn", message: "Something", variant: "warning", duration: 5000 },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("createToast", () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
vi.useRealTimers();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns a function that calls showToast with default delay from config", () => {
|
|
88
|
+
const tui = { showToast: vi.fn() };
|
|
89
|
+
const config = { ui: { toastDelayMs: 3000 } };
|
|
90
|
+
const toastFn = createToast(config);
|
|
91
|
+
|
|
92
|
+
toastFn(tui, "Title", "Msg");
|
|
93
|
+
vi.advanceTimersByTime(3000);
|
|
94
|
+
expect(tui.showToast).toHaveBeenCalledWith({
|
|
95
|
+
body: { title: "Title", message: "Msg", variant: "info", duration: 5000 },
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("uses global DEFAULTS when config has no ui.toastDelayMs", () => {
|
|
100
|
+
const tui = { showToast: vi.fn() };
|
|
101
|
+
const toastFn = createToast({});
|
|
102
|
+
|
|
103
|
+
toastFn(tui, "Title", "Msg");
|
|
104
|
+
vi.advanceTimersByTime(7000);
|
|
105
|
+
expect(tui.showToast).toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("allows overriding delay via delayMs parameter", () => {
|
|
109
|
+
const tui = { showToast: vi.fn() };
|
|
110
|
+
const toastFn = createToast({ ui: { toastDelayMs: 3000 } });
|
|
111
|
+
|
|
112
|
+
toastFn(tui, "Title", "Msg", "info", 100);
|
|
113
|
+
vi.advanceTimersByTime(100);
|
|
114
|
+
expect(tui.showToast).toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("prefers explicit delayMs over config default", () => {
|
|
118
|
+
const tui = { showToast: vi.fn() };
|
|
119
|
+
const toastFn = createToast({ ui: { toastDelayMs: 5000 } });
|
|
120
|
+
|
|
121
|
+
toastFn(tui, "Title", "Msg", "info", 50);
|
|
122
|
+
vi.advanceTimersByTime(50);
|
|
123
|
+
expect(tui.showToast).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
function makeSearchResult(content: string, category?: string): SearchResult {
|
|
128
|
+
return {
|
|
129
|
+
memory: {
|
|
130
|
+
id: "mem-1",
|
|
131
|
+
content,
|
|
132
|
+
category: category ?? null,
|
|
133
|
+
tags: [],
|
|
134
|
+
tier: "work",
|
|
135
|
+
visibility: "global",
|
|
136
|
+
created_at: new Date().toISOString(),
|
|
137
|
+
updated_at: new Date().toISOString(),
|
|
138
|
+
score: 0.9,
|
|
139
|
+
space_id: "space-1",
|
|
140
|
+
tenant_id: "tenant-1",
|
|
141
|
+
},
|
|
142
|
+
score: 0.9,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe("createCerebroCompactionPrompt", () => {
|
|
147
|
+
it("contains all required section headers", () => {
|
|
148
|
+
const result = createCerebroCompactionPrompt([], []);
|
|
149
|
+
expect(result).toContain("[Cerebro Compaction Context]");
|
|
150
|
+
expect(result).toContain("## 1. User's Original Request");
|
|
151
|
+
expect(result).toContain("## 2. Final Goal");
|
|
152
|
+
expect(result).toContain("## 3. Work Completed");
|
|
153
|
+
expect(result).toContain("## 4. Remaining Tasks");
|
|
154
|
+
expect(result).toContain("## 5. Prohibited Actions");
|
|
155
|
+
expect(result).toContain("## 6. Existing Project Knowledge");
|
|
156
|
+
expect(result).toContain("IMPORTANT: Output must preserve the user's original language");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("shows 'No project memories' when projectMemories is empty", () => {
|
|
160
|
+
const result = createCerebroCompactionPrompt([], []);
|
|
161
|
+
expect(result).toContain("(No project memories retrieved)");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("includes project memories when provided", () => {
|
|
165
|
+
const memories = [
|
|
166
|
+
makeSearchResult("Built auth module", "cases"),
|
|
167
|
+
makeSearchResult("Prefers dark mode", "preferences"),
|
|
168
|
+
];
|
|
169
|
+
const result = createCerebroCompactionPrompt([], memories);
|
|
170
|
+
expect(result).toContain("[cases] Built auth module");
|
|
171
|
+
expect(result).toContain("[preferences] Prefers dark mode");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("truncates long memory content to 200 chars", () => {
|
|
175
|
+
const longContent = "A".repeat(300);
|
|
176
|
+
const memories = [makeSearchResult(longContent)];
|
|
177
|
+
const result = createCerebroCompactionPrompt([], memories);
|
|
178
|
+
expect(result).toContain("A".repeat(200) + "...");
|
|
179
|
+
expect(result).not.toContain("A".repeat(300));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("limits to 10 project memories", () => {
|
|
183
|
+
const memories = Array.from({ length: 15 }, (_, i) =>
|
|
184
|
+
makeSearchResult(`Memory ${i}`, "general"),
|
|
185
|
+
);
|
|
186
|
+
const result = createCerebroCompactionPrompt([], memories);
|
|
187
|
+
// Should contain Memory 0 through Memory 9, not Memory 10+
|
|
188
|
+
expect(result).toContain("Memory 0");
|
|
189
|
+
expect(result).toContain("Memory 9");
|
|
190
|
+
expect(result).not.toContain("Memory 10");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("handles memories with null category as 'general'", () => {
|
|
194
|
+
const memories = [makeSearchResult("Some content", null as any)];
|
|
195
|
+
const result = createCerebroCompactionPrompt([], memories);
|
|
196
|
+
expect(result).toContain("[general] Some content");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("includes additional context when context array is non-empty", () => {
|
|
200
|
+
const result = createCerebroCompactionPrompt(
|
|
201
|
+
["User prefers TypeScript", "Last task: fix auth bug"],
|
|
202
|
+
[],
|
|
203
|
+
);
|
|
204
|
+
expect(result).toContain("### Additional Context");
|
|
205
|
+
expect(result).toContain("User prefers TypeScript");
|
|
206
|
+
expect(result).toContain("Last task: fix auth bug");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("does not include Additional Context section when context is empty", () => {
|
|
210
|
+
const result = createCerebroCompactionPrompt([], []);
|
|
211
|
+
expect(result).not.toContain("### Additional Context");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("handles empty messages/context gracefully", () => {
|
|
215
|
+
const result = createCerebroCompactionPrompt([], []);
|
|
216
|
+
expect(result).toBeTruthy();
|
|
217
|
+
expect(typeof result).toBe("string");
|
|
218
|
+
expect(result.length).toBeGreaterThan(0);
|
|
219
|
+
});
|
|
220
|
+
});
|