mcp-memory-consolidation.test.mjs
549 lines 18.5 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tests for mcp/tools/memory.mjs — Stream 3 (Session 10).
3 *
4 * Covers:
5 * consolidation_history:
6 * - Returns empty array when no consolidation events exist
7 * - Returns at most `limit` records (default 20)
8 * - Records contain expected fields (type, ts, data)
9 *
10 * consolidation_settings (read):
11 * - Returns current daemon config fields
12 * - Returns empty object when daemon section is absent
13 *
14 * consolidation_settings (write):
15 * - Updates a single field and persists to yaml
16 * - Rejects interval_minutes < 1
17 * - Rejects interval_minutes > 43200
18 * - Rejects llm_model containing path separators ('/')
19 * - Does not create unexpected keys in the yaml
20 *
21 * memory_consolidate hosted routing:
22 * - When KNOWTATION_HUB_URL is set, calls the gateway URL (not local)
23 * - Passes dry_run / passes / lookback_hours through to the gateway
24 * - Returns gateway response shape on success
25 * - Returns HUB_TOKEN_REQUIRED error when token env var is missing
26 * - Falls back to local consolidateMemory when hub URL is absent
27 */
28
29 import { describe, it } from 'node:test';
30 import assert from 'node:assert/strict';
31 import yaml from 'js-yaml';
32 import { registerMemoryTools } from '../mcp/tools/memory.mjs';
33
34 // ── Helpers ───────────────────────────────────────────────────────────────────
35
36 function createMockServer() {
37 const tools = {};
38 return {
39 registerTool(name, _schema, handler) {
40 tools[name] = handler;
41 },
42 tools,
43 };
44 }
45
46 function parseResult(result) {
47 const text = result.content?.[0]?.text;
48 return text ? JSON.parse(text) : null;
49 }
50
51 function makeMockMM(events = []) {
52 return {
53 list(opts) {
54 let filtered = [...events];
55 if (opts.type) filtered = filtered.filter((e) => e.type === opts.type);
56 const limit = opts.limit ?? filtered.length;
57 return filtered.slice(0, limit);
58 },
59 };
60 }
61
62 function makeConsolidationEvent(overrides = {}) {
63 return {
64 id: 'mem_' + Math.random().toString(36).slice(2, 8),
65 type: 'consolidation',
66 ts: new Date().toISOString(),
67 vault_id: 'default',
68 data: { topics: 2, merged: 4, cost_usd: 0.003 },
69 status: 'success',
70 ...overrides,
71 };
72 }
73
74 const ALLOWED_DAEMON_KEYS = new Set([
75 'enabled', 'interval_minutes', 'idle_only', 'idle_threshold_minutes',
76 'run_on_start', 'max_cost_per_day_usd', 'llm',
77 ]);
78
79 /**
80 * Save and restore env vars around an async callback.
81 */
82 async function withEnv(overrides, fn) {
83 const saved = {};
84 for (const [key, val] of Object.entries(overrides)) {
85 saved[key] = process.env[key];
86 if (val == null) delete process.env[key];
87 else process.env[key] = val;
88 }
89 try {
90 return await fn();
91 } finally {
92 for (const [key, val] of Object.entries(saved)) {
93 if (val === undefined) delete process.env[key];
94 else process.env[key] = val;
95 }
96 }
97 }
98
99 function mockFs(yamlContent = '') {
100 let written = null;
101 return {
102 fs: {
103 existsSync: () => yamlContent !== null,
104 readFileSync: () => yamlContent ?? '',
105 writeFileSync: (_p, data) => { written = data; },
106 mkdirSync: () => {},
107 },
108 getWritten: () => written,
109 };
110 }
111
112 // ── consolidation_history ─────────────────────────────────────────────────────
113
114 describe('consolidation_history', () => {
115 it('returns empty array when no consolidation events exist', async () => {
116 const server = createMockServer();
117 registerMemoryTools(server, {
118 loadConfig: () => ({ memory: { enabled: true }, daemon: {} }),
119 createMemoryManager: () => makeMockMM([]),
120 });
121
122 const result = await server.tools.consolidation_history({});
123 const data = parseResult(result);
124
125 assert.deepEqual(data.history, []);
126 assert.equal(data.count, 0);
127 });
128
129 it('returns at most `limit` records (default 20)', async () => {
130 const events = Array.from({ length: 30 }, () => makeConsolidationEvent());
131 const server = createMockServer();
132 registerMemoryTools(server, {
133 loadConfig: () => ({ memory: { enabled: true }, daemon: {} }),
134 createMemoryManager: () => makeMockMM(events),
135 });
136
137 const result = await server.tools.consolidation_history({});
138 const data = parseResult(result);
139
140 assert.equal(data.count, 20);
141 assert.equal(data.history.length, 20);
142 });
143
144 it('respects explicit limit', async () => {
145 const events = Array.from({ length: 10 }, () => makeConsolidationEvent());
146 const server = createMockServer();
147 registerMemoryTools(server, {
148 loadConfig: () => ({ memory: { enabled: true }, daemon: {} }),
149 createMemoryManager: () => makeMockMM(events),
150 });
151
152 const result = await server.tools.consolidation_history({ limit: 3 });
153 const data = parseResult(result);
154
155 assert.equal(data.count, 3);
156 assert.equal(data.history.length, 3);
157 });
158
159 it('records contain expected fields (type, ts, data)', async () => {
160 const events = [
161 makeConsolidationEvent({
162 ts: '2026-04-05T00:00:00.000Z',
163 data: { topics: 3, merged: 5, cost_usd: 0.004 },
164 }),
165 ];
166 const server = createMockServer();
167 registerMemoryTools(server, {
168 loadConfig: () => ({ memory: { enabled: true }, daemon: {} }),
169 createMemoryManager: () => makeMockMM(events),
170 });
171
172 const result = await server.tools.consolidation_history({ limit: 10 });
173 const data = parseResult(result);
174
175 assert.equal(data.count, 1);
176 const record = data.history[0];
177 assert.equal(record.type, 'consolidation');
178 assert.equal(record.ts, '2026-04-05T00:00:00.000Z');
179 assert.ok(record.data);
180 assert.equal(record.data.topics, 3);
181 assert.equal(record.data.merged, 5);
182 });
183 });
184
185 // ── consolidation_settings (read) ─────────────────────────────────────────────
186
187 describe('consolidation_settings (read)', () => {
188 it('returns current daemon config fields', async () => {
189 const server = createMockServer();
190 registerMemoryTools(server, {
191 loadConfig: () => ({
192 memory: { enabled: true },
193 daemon: { enabled: true, interval_minutes: 120, idle_only: true, max_cost_per_day_usd: 0.50 },
194 }),
195 });
196
197 const result = await server.tools.consolidation_settings({});
198 const data = parseResult(result);
199
200 assert.equal(data.daemon.enabled, true);
201 assert.equal(data.daemon.interval_minutes, 120);
202 assert.equal(data.daemon.idle_only, true);
203 assert.equal(data.daemon.max_cost_per_day_usd, 0.50);
204 });
205
206 it('returns empty object when daemon section is absent', async () => {
207 const server = createMockServer();
208 registerMemoryTools(server, {
209 loadConfig: () => ({ memory: { enabled: true } }),
210 });
211
212 const result = await server.tools.consolidation_settings({});
213 const data = parseResult(result);
214
215 assert.deepEqual(data.daemon, {});
216 });
217 });
218
219 // ── consolidation_settings (write) ────────────────────────────────────────────
220
221 describe('consolidation_settings (write)', () => {
222 it('updates a single field and persists to yaml', async () => {
223 const existingYaml = yaml.dump({ daemon: { enabled: false, interval_minutes: 60 } });
224 const { fs: mockFsObj, getWritten } = mockFs(existingYaml);
225
226 const server = createMockServer();
227 registerMemoryTools(server, {
228 resolveConfigPath: () => '/tmp/test-config.yaml',
229 fs: mockFsObj,
230 });
231
232 const result = await server.tools.consolidation_settings({ enabled: true });
233 const data = parseResult(result);
234
235 assert.equal(data.ok, true);
236 assert.equal(data.daemon.enabled, true);
237 assert.equal(data.daemon.interval_minutes, 60);
238
239 const parsed = yaml.load(getWritten());
240 assert.equal(parsed.daemon.enabled, true);
241 assert.equal(parsed.daemon.interval_minutes, 60);
242 });
243
244 it('creates config file when it does not exist', async () => {
245 let dirCreated = false;
246 const server = createMockServer();
247 registerMemoryTools(server, {
248 resolveConfigPath: () => '/tmp/nonexistent/local.yaml',
249 fs: {
250 existsSync: () => false,
251 readFileSync: () => '',
252 writeFileSync: () => {},
253 mkdirSync: () => { dirCreated = true; },
254 },
255 });
256
257 const result = await server.tools.consolidation_settings({ enabled: true });
258 const data = parseResult(result);
259
260 assert.equal(data.ok, true);
261 assert.equal(data.daemon.enabled, true);
262 assert.equal(dirCreated, true);
263 });
264
265 it('rejects interval_minutes < 1', async () => {
266 const { fs: mockFsObj } = mockFs(yaml.dump({}));
267 const server = createMockServer();
268 registerMemoryTools(server, {
269 resolveConfigPath: () => '/tmp/test.yaml',
270 fs: mockFsObj,
271 });
272
273 const result = await server.tools.consolidation_settings({ interval_minutes: 0 });
274 const data = parseResult(result);
275
276 assert.equal(result.isError, true);
277 assert.equal(data.code, 'VALIDATION_ERROR');
278 assert.ok(data.error.includes('interval_minutes'));
279 });
280
281 it('rejects interval_minutes > 43200', async () => {
282 const { fs: mockFsObj } = mockFs(yaml.dump({}));
283 const server = createMockServer();
284 registerMemoryTools(server, {
285 resolveConfigPath: () => '/tmp/test.yaml',
286 fs: mockFsObj,
287 });
288
289 const result = await server.tools.consolidation_settings({ interval_minutes: 50000 });
290 const data = parseResult(result);
291
292 assert.equal(result.isError, true);
293 assert.equal(data.code, 'VALIDATION_ERROR');
294 assert.ok(data.error.includes('interval_minutes'));
295 });
296
297 it('rejects llm_model containing path separators ("/")', async () => {
298 const { fs: mockFsObj } = mockFs(yaml.dump({}));
299 const server = createMockServer();
300 registerMemoryTools(server, {
301 resolveConfigPath: () => '/tmp/test.yaml',
302 fs: mockFsObj,
303 });
304
305 const result = await server.tools.consolidation_settings({ llm_model: '../etc/passwd' });
306 const data = parseResult(result);
307
308 assert.equal(result.isError, true);
309 assert.equal(data.code, 'VALIDATION_ERROR');
310 assert.ok(data.error.includes('llm_model'));
311 });
312
313 it('rejects llm_model containing shell metacharacters', async () => {
314 const { fs: mockFsObj } = mockFs(yaml.dump({}));
315 const server = createMockServer();
316 registerMemoryTools(server, {
317 resolveConfigPath: () => '/tmp/test.yaml',
318 fs: mockFsObj,
319 });
320
321 const result = await server.tools.consolidation_settings({ llm_model: 'model; rm -rf /' });
322 const data = parseResult(result);
323
324 assert.equal(result.isError, true);
325 assert.equal(data.code, 'VALIDATION_ERROR');
326 });
327
328 it('does not create unexpected keys in the yaml', async () => {
329 const existingYaml = yaml.dump({
330 vault_path: './vault',
331 daemon: { enabled: false },
332 });
333 const { fs: mockFsObj, getWritten } = mockFs(existingYaml);
334
335 const server = createMockServer();
336 registerMemoryTools(server, {
337 resolveConfigPath: () => '/tmp/test.yaml',
338 fs: mockFsObj,
339 });
340
341 await server.tools.consolidation_settings({ enabled: true });
342
343 const parsed = yaml.load(getWritten());
344 assert.equal(parsed.vault_path, './vault', 'non-daemon keys must be preserved');
345 for (const key of Object.keys(parsed.daemon)) {
346 assert.ok(ALLOWED_DAEMON_KEYS.has(key), `unexpected daemon key: ${key}`);
347 }
348 });
349 });
350
351 // ── memory_consolidate hosted routing ─────────────────────────────────────────
352
353 describe('memory_consolidate hosted routing', () => {
354 it('calls the gateway URL when KNOWTATION_HUB_URL is set (not local)', async () => {
355 await withEnv(
356 { KNOWTATION_HUB_URL: 'https://hub.example.com', KNOWTATION_HUB_TOKEN: 'test-token' },
357 async () => {
358 let capturedUrl = null;
359 let localCalled = false;
360
361 const server = createMockServer();
362 registerMemoryTools(server, {
363 loadConfig: () => ({ memory: { enabled: true }, daemon: {} }),
364 consolidateMemory: async () => { localCalled = true; return {}; },
365 fetchFn: async (url) => {
366 capturedUrl = url;
367 return {
368 ok: true,
369 text: async () => JSON.stringify({
370 topics: 2, total_events: 10, cost_usd: 0.003,
371 pass_id: 'cpass_1', dry_run: false, verify: true, discover: false,
372 }),
373 };
374 },
375 });
376
377 const result = await server.tools.memory_consolidate({});
378 const data = parseResult(result);
379
380 assert.equal(capturedUrl, 'https://hub.example.com/api/v1/memory/consolidate');
381 assert.equal(localCalled, false, 'local consolidateMemory must not be called in hosted mode');
382 assert.equal(data.topics, 2);
383 },
384 );
385 });
386
387 it('passes dry_run / passes / lookback_hours through to the gateway', async () => {
388 await withEnv(
389 { KNOWTATION_HUB_URL: 'https://hub.example.com', KNOWTATION_HUB_TOKEN: 'tok' },
390 async () => {
391 let capturedBody = null;
392
393 const server = createMockServer();
394 registerMemoryTools(server, {
395 loadConfig: () => ({ memory: { enabled: true }, daemon: {} }),
396 fetchFn: async (_url, init) => {
397 capturedBody = JSON.parse(init.body);
398 return {
399 ok: true,
400 text: async () => JSON.stringify({ topics: 1, total_events: 5 }),
401 };
402 },
403 });
404
405 await server.tools.memory_consolidate({
406 dry_run: true,
407 passes: ['consolidate', 'verify'],
408 lookback_hours: 48,
409 });
410
411 assert.equal(capturedBody.dry_run, true);
412 assert.deepEqual(capturedBody.passes, ['consolidate', 'verify']);
413 assert.equal(capturedBody.lookback_hours, 48);
414 },
415 );
416 });
417
418 it('returns gateway response shape on success', async () => {
419 await withEnv(
420 { KNOWTATION_HUB_URL: 'https://hub.example.com', KNOWTATION_HUB_TOKEN: 'tok' },
421 async () => {
422 const gatewayResponse = {
423 topics: 3, total_events: 20, verify: true, discover: false,
424 cost_usd: 0.007, pass_id: 'cpass_abc', dry_run: false,
425 };
426
427 const server = createMockServer();
428 registerMemoryTools(server, {
429 loadConfig: () => ({ memory: { enabled: true }, daemon: {} }),
430 fetchFn: async () => ({
431 ok: true,
432 text: async () => JSON.stringify(gatewayResponse),
433 }),
434 });
435
436 const result = await server.tools.memory_consolidate({});
437 const data = parseResult(result);
438
439 assert.equal(data.topics, 3);
440 assert.equal(data.total_events, 20);
441 assert.equal(data.verify, true);
442 assert.equal(data.discover, false);
443 assert.equal(data.cost_usd, 0.007);
444 assert.equal(data.pass_id, 'cpass_abc');
445 assert.equal(data.dry_run, false);
446 assert.equal(result.isError, undefined);
447 },
448 );
449 });
450
451 it('returns HUB_TOKEN_REQUIRED error when token env var is missing', async () => {
452 await withEnv(
453 { KNOWTATION_HUB_URL: 'https://hub.example.com', KNOWTATION_HUB_TOKEN: undefined },
454 async () => {
455 const server = createMockServer();
456 registerMemoryTools(server, {
457 loadConfig: () => ({ memory: { enabled: true }, daemon: {} }),
458 fetchFn: async () => { throw new Error('fetch must not be called'); },
459 });
460
461 const result = await server.tools.memory_consolidate({});
462 const data = parseResult(result);
463
464 assert.equal(result.isError, true);
465 assert.equal(data.code, 'HUB_TOKEN_REQUIRED');
466 assert.ok(data.error.includes('KNOWTATION_HUB_TOKEN'));
467 },
468 );
469 });
470
471 it('surfaces bridge error on non-2xx response', async () => {
472 await withEnv(
473 { KNOWTATION_HUB_URL: 'https://hub.example.com', KNOWTATION_HUB_TOKEN: 'tok' },
474 async () => {
475 const server = createMockServer();
476 registerMemoryTools(server, {
477 loadConfig: () => ({ memory: { enabled: true }, daemon: {} }),
478 fetchFn: async () => ({
479 ok: false,
480 status: 503,
481 statusText: 'Service Unavailable',
482 text: async () => JSON.stringify({ error: 'consolidation queue full', code: 'QUEUE_FULL' }),
483 }),
484 });
485
486 const result = await server.tools.memory_consolidate({});
487 const data = parseResult(result);
488
489 assert.equal(result.isError, true);
490 assert.ok(data.error.includes('consolidation queue full'));
491 },
492 );
493 });
494
495 it('falls back to local consolidateMemory when hub URL is absent', async () => {
496 await withEnv(
497 { KNOWTATION_HUB_URL: undefined, KNOWTATION_HUB_TOKEN: undefined },
498 async () => {
499 let localCalled = false;
500 let localArgs = null;
501
502 const server = createMockServer();
503 registerMemoryTools(server, {
504 loadConfig: () => ({ memory: { enabled: true }, daemon: {} }),
505 consolidateMemory: async (_config, args) => {
506 localCalled = true;
507 localArgs = args;
508 return { topics: 1, total_events: 5, cost_usd: 0.001, pass_id: 'cpass_local' };
509 },
510 fetchFn: async () => { throw new Error('fetch must not be called in local mode'); },
511 });
512
513 const result = await server.tools.memory_consolidate({ dry_run: true, lookback_hours: 12 });
514 const data = parseResult(result);
515
516 assert.equal(localCalled, true, 'local consolidateMemory must be called when hub URL is absent');
517 assert.equal(localArgs.dryRun, true);
518 assert.equal(localArgs.lookbackHours, 12);
519 assert.equal(data.topics, 1);
520 assert.equal(data.pass_id, 'cpass_local');
521 },
522 );
523 });
524
525 it('detects hub URL from config.hub_url when env var is unset', async () => {
526 await withEnv(
527 { KNOWTATION_HUB_URL: undefined, KNOWTATION_HUB_TOKEN: 'tok' },
528 async () => {
529 let capturedUrl = null;
530
531 const server = createMockServer();
532 registerMemoryTools(server, {
533 loadConfig: () => ({
534 memory: { enabled: true },
535 daemon: {},
536 hub_url: 'https://config-hub.example.com/',
537 }),
538 fetchFn: async (url) => {
539 capturedUrl = url;
540 return { ok: true, text: async () => JSON.stringify({ topics: 1 }) };
541 },
542 });
543
544 await server.tools.memory_consolidate({});
545 assert.equal(capturedUrl, 'https://config-hub.example.com/api/v1/memory/consolidate');
546 },
547 );
548 });
549 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago