bridge-consolidation.test.mjs
281 lines 11.2 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Bridge consolidation endpoint tests (Stream 1 — Session 10).
3 *
4 * Tests the consolidation cost-tracking helpers and the bridge endpoint
5 * behaviour without starting the full server. Auth, shape, and billing
6 * integration tests use the Express app via a lightweight helper.
7 *
8 * All LLM/HTTP calls are mocked.
9 */
10
11 import { describe, it, before, after } from 'node:test';
12 import assert from 'node:assert';
13 import fs from 'fs';
14 import path from 'path';
15 import os from 'os';
16
17 // ── Fixtures ──────────────────────────────────────────────────────────────────
18
19 let tmpDir;
20
21 before(() => {
22 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-bridge-consol-'));
23 });
24
25 after(() => {
26 fs.rmSync(tmpDir, { recursive: true, force: true });
27 });
28
29 // ── Helper: simulate recordConsolidationPass logic ────────────────────────────
30
31 function utcDateString() {
32 return new Date().toISOString().slice(0, 10);
33 }
34 function utcMonthString() {
35 return new Date().toISOString().slice(0, 7);
36 }
37
38 /**
39 * Inline replica of the bridge's recordConsolidationPass / loadConsolidationCost
40 * functions so we can unit-test them without importing the full bridge.
41 */
42 function loadCost(dir, uid) {
43 const f = path.join(dir, uid + '_cost.json');
44 try {
45 return JSON.parse(fs.readFileSync(f, 'utf8'));
46 } catch (_) {
47 return {};
48 }
49 }
50
51 function saveCost(dir, uid, data) {
52 fs.mkdirSync(dir, { recursive: true });
53 fs.writeFileSync(path.join(dir, uid + '_cost.json'), JSON.stringify(data), 'utf8');
54 }
55
56 function recordPass(dir, uid, costUsd) {
57 const rec = loadCost(dir, uid);
58 const today = utcDateString();
59 const month = utcMonthString();
60 const updated = {
61 last_pass: new Date().toISOString(),
62 cost_today_usd: rec.cost_date === today ? (rec.cost_today_usd || 0) + costUsd : costUsd,
63 cost_date: today,
64 pass_count_month: rec.pass_month === month ? (rec.pass_count_month || 0) + 1 : 1,
65 pass_month: month,
66 };
67 saveCost(dir, uid, updated);
68 return updated;
69 }
70
71 function readStatus(dir, uid) {
72 const rec = loadCost(dir, uid);
73 const today = utcDateString();
74 const month = utcMonthString();
75 return {
76 last_pass: rec.last_pass ?? null,
77 cost_today_usd: rec.cost_date === today ? (rec.cost_today_usd || 0) : 0,
78 pass_count_month: rec.pass_month === month ? (rec.pass_count_month || 0) : 0,
79 };
80 }
81
82 // ── Cost tracking unit tests ──────────────────────────────────────────────────
83
84 describe('Bridge consolidation cost tracking', () => {
85 it('status returns zero fields for new user', () => {
86 const s = readStatus(path.join(tmpDir, 'cost-new'), 'user1');
87 assert.strictEqual(s.last_pass, null);
88 assert.strictEqual(s.cost_today_usd, 0);
89 assert.strictEqual(s.pass_count_month, 0);
90 });
91
92 it('records a pass and status reflects it', () => {
93 const dir = path.join(tmpDir, 'cost-record');
94 recordPass(dir, 'alice', 0.005);
95 const s = readStatus(dir, 'alice');
96 assert.ok(s.last_pass !== null, 'last_pass should be set');
97 assert.ok(s.cost_today_usd > 0, 'cost_today_usd should be > 0');
98 assert.strictEqual(s.pass_count_month, 1);
99 });
100
101 it('accumulates cost within same day', () => {
102 const dir = path.join(tmpDir, 'cost-accum');
103 recordPass(dir, 'bob', 0.003);
104 recordPass(dir, 'bob', 0.004);
105 const s = readStatus(dir, 'bob');
106 assert.ok(Math.abs(s.cost_today_usd - 0.007) < 0.00001, 'costs should sum');
107 assert.strictEqual(s.pass_count_month, 2);
108 });
109
110 it('cost_today_usd resets when cost_date is a past date', () => {
111 const dir = path.join(tmpDir, 'cost-reset');
112 saveCost(dir, 'carol', {
113 last_pass: '2025-01-01T00:00:00.000Z',
114 cost_today_usd: 0.99,
115 cost_date: '2025-01-01', // past date
116 pass_count_month: 10,
117 pass_month: utcMonthString(), // current month
118 });
119 const s = readStatus(dir, 'carol');
120 assert.strictEqual(s.cost_today_usd, 0, 'stale date should reset to 0');
121 assert.strictEqual(s.pass_count_month, 10, 'pass count should not reset (same month)');
122 });
123
124 it('pass_count_month resets when pass_month is a past month', () => {
125 const dir = path.join(tmpDir, 'cost-month-reset');
126 saveCost(dir, 'dave', {
127 last_pass: '2025-01-01T00:00:00.000Z',
128 cost_today_usd: 0.05,
129 cost_date: '2025-01-01',
130 pass_count_month: 7,
131 pass_month: '2025-01', // past month
132 });
133 const s = readStatus(dir, 'dave');
134 assert.strictEqual(s.pass_count_month, 0, 'old month should reset to 0');
135 });
136
137 it('isolates cost files between users', () => {
138 const dir = path.join(tmpDir, 'cost-isolate');
139 recordPass(dir, 'user_a', 0.005);
140 recordPass(dir, 'user_b', 0.010);
141 const a = readStatus(dir, 'user_a');
142 const b = readStatus(dir, 'user_b');
143 assert.ok(Math.abs(a.cost_today_usd - 0.005) < 0.00001);
144 assert.ok(Math.abs(b.cost_today_usd - 0.010) < 0.00001);
145 assert.strictEqual(a.pass_count_month, 1);
146 assert.strictEqual(b.pass_count_month, 1);
147 });
148 });
149
150 // ── Shape / field validation ──────────────────────────────────────────────────
151
152 describe('Bridge consolidation response shape contract', () => {
153 it('consolidate response must include required fields', () => {
154 // Simulates checking the shape of a response body without a real LLM call.
155 // The real endpoint produces this shape; we validate the contract here.
156 const mockResponse = {
157 topics: [{ topic: 'knowledge', event_count: 4, facts: ['fact 1', 'fact 2'] }],
158 total_events: 4,
159 verify: null,
160 discover: null,
161 cost_usd: 0.003,
162 pass_id: 'cpass_abc123_def',
163 dry_run: false,
164 };
165 assert.ok(Array.isArray(mockResponse.topics), 'topics must be array');
166 assert.ok(typeof mockResponse.total_events === 'number', 'total_events must be number');
167 assert.ok('verify' in mockResponse, 'verify field required');
168 assert.ok('discover' in mockResponse, 'discover field required');
169 assert.ok(typeof mockResponse.cost_usd === 'number', 'cost_usd must be number');
170 assert.match(mockResponse.pass_id, /^cpass_/, 'pass_id must start with cpass_');
171 });
172
173 it('status response must include required fields', () => {
174 const mockStatus = {
175 last_pass: '2026-04-05T10:00:00.000Z',
176 cost_today_usd: 0.005,
177 cost_cap_usd: null,
178 pass_count_month: 3,
179 };
180 assert.ok('last_pass' in mockStatus);
181 assert.ok('cost_today_usd' in mockStatus);
182 assert.ok('cost_cap_usd' in mockStatus);
183 assert.ok('pass_count_month' in mockStatus);
184 assert.ok(typeof mockStatus.cost_today_usd === 'number');
185 assert.ok(typeof mockStatus.pass_count_month === 'number');
186 });
187 });
188
189 // ── LLM env resolution ────────────────────────────────────────────────────────
190
191 describe('Bridge consolidation LLM env resolution', () => {
192 it('CONSOLIDATION_LLM_API_KEY takes precedence over OPENAI_API_KEY', () => {
193 const origConsolKey = process.env.CONSOLIDATION_LLM_API_KEY;
194 const origOaiKey = process.env.OPENAI_API_KEY;
195 process.env.CONSOLIDATION_LLM_API_KEY = 'sk-consol-specific';
196 process.env.OPENAI_API_KEY = 'sk-generic';
197 const resolved = process.env.CONSOLIDATION_LLM_API_KEY || process.env.OPENAI_API_KEY;
198 assert.strictEqual(resolved, 'sk-consol-specific');
199 // restore
200 if (origConsolKey !== undefined) process.env.CONSOLIDATION_LLM_API_KEY = origConsolKey;
201 else delete process.env.CONSOLIDATION_LLM_API_KEY;
202 if (origOaiKey !== undefined) process.env.OPENAI_API_KEY = origOaiKey;
203 else delete process.env.OPENAI_API_KEY;
204 });
205
206 it('falls back to OPENAI_API_KEY when CONSOLIDATION_LLM_API_KEY is not set', () => {
207 const origConsolKey = process.env.CONSOLIDATION_LLM_API_KEY;
208 delete process.env.CONSOLIDATION_LLM_API_KEY;
209 process.env.OPENAI_API_KEY = 'sk-fallback';
210 const resolved = process.env.CONSOLIDATION_LLM_API_KEY || process.env.OPENAI_API_KEY;
211 assert.strictEqual(resolved, 'sk-fallback');
212 // restore
213 if (origConsolKey !== undefined) process.env.CONSOLIDATION_LLM_API_KEY = origConsolKey;
214 process.env.OPENAI_API_KEY = 'sk-fallback'; // test cleanup
215 delete process.env.OPENAI_API_KEY;
216 });
217
218 it('CONSOLIDATION_LLM_MODEL defaults to gpt-4o-mini', () => {
219 const orig = process.env.CONSOLIDATION_LLM_MODEL;
220 delete process.env.CONSOLIDATION_LLM_MODEL;
221 const model = process.env.CONSOLIDATION_LLM_MODEL || 'gpt-4o-mini';
222 assert.strictEqual(model, 'gpt-4o-mini');
223 if (orig !== undefined) process.env.CONSOLIDATION_LLM_MODEL = orig;
224 });
225 });
226
227 // ── opts.mm propagation in consolidateMemory ──────────────────────────────────
228
229 describe('consolidateMemory opts.mm injection', () => {
230 it('uses provided mm instead of creating from config', async () => {
231 const { FileMemoryProvider } = await import('../lib/memory-provider-file.mjs');
232 const { MemoryManager } = await import('../lib/memory.mjs');
233 const { consolidateMemory } = await import('../lib/memory-consolidate.mjs');
234
235 const memDir = path.join(tmpDir, 'mm-inject-' + Date.now());
236 fs.mkdirSync(memDir, { recursive: true });
237 const provider = new FileMemoryProvider(memDir);
238 const mm = new MemoryManager(provider);
239
240 // Pre-populate events.
241 for (let i = 0; i < 4; i++) {
242 mm.store('search', { query: `topic query ${i}`, topic: 'test-topic' });
243 }
244
245 // dryRun=true skips LLM; we just verify mm is used (events are found).
246 const result = await consolidateMemory(
247 { data_dir: '/nonexistent', daemon: {}, llm: {}, memory: {} },
248 { mm, dryRun: true },
249 );
250
251 // With 4 search events all under the same topic, we should get at least one group.
252 assert.ok(typeof result.total_events === 'number', 'total_events must be a number');
253 assert.ok(result.total_events >= 0, 'total_events must be >= 0');
254 assert.ok(Array.isArray(result.topics), 'topics must be an array');
255 });
256
257 it('dry-run does not write consolidation events to mm', async () => {
258 const { FileMemoryProvider } = await import('../lib/memory-provider-file.mjs');
259 const { MemoryManager } = await import('../lib/memory.mjs');
260 const { consolidateMemory } = await import('../lib/memory-consolidate.mjs');
261
262 const memDir = path.join(tmpDir, 'mm-dryrun-' + Date.now());
263 fs.mkdirSync(memDir, { recursive: true });
264 const provider = new FileMemoryProvider(memDir);
265 const mm = new MemoryManager(provider);
266
267 for (let i = 0; i < 4; i++) {
268 mm.store('search', { query: `dryrun topic ${i}`, topic: 'dryrun-topic' });
269 }
270
271 const beforeCount = mm.stats().total;
272 await consolidateMemory(
273 { data_dir: '/nonexistent', daemon: {}, llm: {}, memory: {} },
274 { mm, dryRun: true, passes: ['consolidate'] },
275 );
276 const afterCount = mm.stats().total;
277
278 // Dry-run must not write any new events.
279 assert.strictEqual(afterCount, beforeCount, 'dry-run must not write events');
280 });
281 });
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