bridge-consolidation.test.mjs
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