daemon-cost.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Tests for lib/daemon-cost.mjs — Phase F: Cost Tracking and Guardrails. |
| 3 | * |
| 4 | * Covers: |
| 5 | * 1. estimateTokens — empty input, short text, long text, non-string |
| 6 | * 2. computeCallCost — default rates, custom rates, empty inputs, zero response |
| 7 | * 3. getCostFilePath — correct path resolution |
| 8 | * 4. utcDateString — returns YYYY-MM-DD format, injectable date |
| 9 | * 5. getDailyCost — missing file, missing date key, existing entry, other-date isolation |
| 10 | * 6. recordCallCost — creates file, accumulates, ignores zero/negative, handles corrupt file |
| 11 | * 7. resetDailyCost — writes empty object, subsequent getDailyCost returns 0 |
| 12 | * |
| 13 | * All filesystem I/O uses a temp directory. No LLM calls. No network access. |
| 14 | */ |
| 15 | |
| 16 | import { describe, it, before, after } from 'node:test'; |
| 17 | import assert from 'node:assert'; |
| 18 | import fs from 'fs'; |
| 19 | import path from 'path'; |
| 20 | import os from 'os'; |
| 21 | |
| 22 | import { |
| 23 | DEFAULT_RATES, |
| 24 | estimateTokens, |
| 25 | computeCallCost, |
| 26 | getCostFilePath, |
| 27 | utcDateString, |
| 28 | getDailyCost, |
| 29 | recordCallCost, |
| 30 | resetDailyCost, |
| 31 | } from '../lib/daemon-cost.mjs'; |
| 32 | |
| 33 | // ── Test fixtures ───────────────────────────────────────────────────────────── |
| 34 | |
| 35 | let tmpDir; |
| 36 | |
| 37 | before(() => { |
| 38 | tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-cost-test-')); |
| 39 | }); |
| 40 | |
| 41 | after(() => { |
| 42 | fs.rmSync(tmpDir, { recursive: true, force: true }); |
| 43 | }); |
| 44 | |
| 45 | function makeConfig(suffix = '') { |
| 46 | const dataDir = path.join( |
| 47 | tmpDir, |
| 48 | `data-${Date.now()}-${suffix || Math.random().toString(36).slice(2)}`, |
| 49 | ); |
| 50 | fs.mkdirSync(dataDir, { recursive: true }); |
| 51 | return { data_dir: dataDir }; |
| 52 | } |
| 53 | |
| 54 | // ── 1. estimateTokens ───────────────────────────────────────────────────────── |
| 55 | |
| 56 | describe('estimateTokens', () => { |
| 57 | it('returns 0 for empty string', () => { |
| 58 | assert.strictEqual(estimateTokens(''), 0); |
| 59 | }); |
| 60 | |
| 61 | it('returns 0 for null', () => { |
| 62 | assert.strictEqual(estimateTokens(null), 0); |
| 63 | }); |
| 64 | |
| 65 | it('returns 0 for undefined', () => { |
| 66 | assert.strictEqual(estimateTokens(undefined), 0); |
| 67 | }); |
| 68 | |
| 69 | it('returns 0 for non-string number', () => { |
| 70 | assert.strictEqual(estimateTokens(42), 0); |
| 71 | }); |
| 72 | |
| 73 | it('returns ceil(length / 4) for a 4-char string', () => { |
| 74 | assert.strictEqual(estimateTokens('abcd'), 1); |
| 75 | }); |
| 76 | |
| 77 | it('returns ceil(length / 4) for a 5-char string', () => { |
| 78 | assert.strictEqual(estimateTokens('abcde'), 2); |
| 79 | }); |
| 80 | |
| 81 | it('returns ceil(length / 4) for a 16-char string', () => { |
| 82 | assert.strictEqual(estimateTokens('abcdefghijklmnop'), 4); |
| 83 | }); |
| 84 | |
| 85 | it('returns ceil(length / 4) for a 1-char string', () => { |
| 86 | assert.strictEqual(estimateTokens('x'), 1); |
| 87 | }); |
| 88 | |
| 89 | it('result is always a positive integer for non-empty strings', () => { |
| 90 | const text = 'Hello, world! This is a test of the token estimator.'; |
| 91 | const result = estimateTokens(text); |
| 92 | assert.strictEqual(typeof result, 'number'); |
| 93 | assert(Number.isInteger(result)); |
| 94 | assert(result > 0); |
| 95 | }); |
| 96 | }); |
| 97 | |
| 98 | // ── 2. computeCallCost ──────────────────────────────────────────────────────── |
| 99 | |
| 100 | describe('computeCallCost', () => { |
| 101 | it('returns a non-negative number', () => { |
| 102 | const cost = computeCallCost( |
| 103 | { system: 'You are a helper.', user: 'What is 2+2?' }, |
| 104 | '4', |
| 105 | ); |
| 106 | assert(typeof cost === 'number'); |
| 107 | assert(cost >= 0); |
| 108 | }); |
| 109 | |
| 110 | it('returns 0 for empty opts and empty response', () => { |
| 111 | assert.strictEqual(computeCallCost({}, ''), 0); |
| 112 | }); |
| 113 | |
| 114 | it('returns 0 for null opts and null response', () => { |
| 115 | assert.strictEqual(computeCallCost(null, null), 0); |
| 116 | }); |
| 117 | |
| 118 | it('uses DEFAULT_RATES when no rates supplied', () => { |
| 119 | const system = 'a'.repeat(4); // 1 input token |
| 120 | const user = ''; |
| 121 | const response = 'b'.repeat(4); // 1 output token |
| 122 | const cost = computeCallCost({ system, user }, response); |
| 123 | const expected = |
| 124 | 1 * DEFAULT_RATES.input_per_token + 1 * DEFAULT_RATES.output_per_token; |
| 125 | assert(Math.abs(cost - expected) < 1e-12); |
| 126 | }); |
| 127 | |
| 128 | it('respects custom rates and produces exact values', () => { |
| 129 | // system = 8 chars → 2 input tokens; response = 4 chars → 1 output token |
| 130 | const system = 'a'.repeat(8); |
| 131 | const response = 'b'.repeat(4); |
| 132 | const rates = { input_per_token: 0.01, output_per_token: 0.02 }; |
| 133 | const cost = computeCallCost({ system }, response, rates); |
| 134 | // 2 * 0.01 + 1 * 0.02 = 0.04 |
| 135 | assert(Math.abs(cost - 0.04) < 1e-10); |
| 136 | }); |
| 137 | |
| 138 | it('handles opts with only system (no user)', () => { |
| 139 | const cost = computeCallCost({ system: 'test' }, 'ok'); |
| 140 | assert(cost > 0); |
| 141 | }); |
| 142 | |
| 143 | it('handles opts with only user (no system)', () => { |
| 144 | const cost = computeCallCost({ user: 'hello' }, 'response'); |
| 145 | assert(cost > 0); |
| 146 | }); |
| 147 | |
| 148 | it('partial rate override keeps the un-overridden default', () => { |
| 149 | // Override only output rate; input should remain DEFAULT_RATES.input_per_token |
| 150 | const system = 'a'.repeat(4); // 1 input token |
| 151 | const response = 'b'.repeat(4); // 1 output token |
| 152 | const rates = { output_per_token: 1.0 }; // $1 per output token (absurd, but deterministic) |
| 153 | const cost = computeCallCost({ system }, response, rates); |
| 154 | const expected = 1 * DEFAULT_RATES.input_per_token + 1 * 1.0; |
| 155 | assert(Math.abs(cost - expected) < 1e-12); |
| 156 | }); |
| 157 | }); |
| 158 | |
| 159 | // ── 3. getCostFilePath ──────────────────────────────────────────────────────── |
| 160 | |
| 161 | describe('getCostFilePath', () => { |
| 162 | it('returns {data_dir}/daemon-cost.json', () => { |
| 163 | const config = makeConfig(); |
| 164 | assert.strictEqual( |
| 165 | getCostFilePath(config), |
| 166 | path.join(config.data_dir, 'daemon-cost.json'), |
| 167 | ); |
| 168 | }); |
| 169 | }); |
| 170 | |
| 171 | // ── 4. utcDateString ────────────────────────────────────────────────────────── |
| 172 | |
| 173 | describe('utcDateString', () => { |
| 174 | it('returns a YYYY-MM-DD formatted string', () => { |
| 175 | const d = utcDateString(); |
| 176 | assert.match(d, /^\d{4}-\d{2}-\d{2}$/); |
| 177 | }); |
| 178 | |
| 179 | it('accepts an injectable Date for deterministic tests', () => { |
| 180 | const d = utcDateString(new Date('2026-04-05T15:30:00Z')); |
| 181 | assert.strictEqual(d, '2026-04-05'); |
| 182 | }); |
| 183 | |
| 184 | it('handles UTC midnight correctly', () => { |
| 185 | const d = utcDateString(new Date('2026-01-01T00:00:00Z')); |
| 186 | assert.strictEqual(d, '2026-01-01'); |
| 187 | }); |
| 188 | }); |
| 189 | |
| 190 | // ── 5. getDailyCost ─────────────────────────────────────────────────────────── |
| 191 | |
| 192 | describe('getDailyCost', () => { |
| 193 | it('returns 0 when cost file does not exist', () => { |
| 194 | const config = makeConfig('no-file'); |
| 195 | assert.strictEqual(getDailyCost(config), 0); |
| 196 | }); |
| 197 | |
| 198 | it('returns 0 when the file has no entry for the requested date', () => { |
| 199 | const config = makeConfig('missing-key'); |
| 200 | resetDailyCost(config); |
| 201 | assert.strictEqual(getDailyCost(config, '2020-01-01'), 0); |
| 202 | }); |
| 203 | |
| 204 | it('returns the stored value for the requested date', () => { |
| 205 | const config = makeConfig('existing-entry'); |
| 206 | const date = '2026-04-05'; |
| 207 | recordCallCost(config, 0.0123, date); |
| 208 | assert(Math.abs(getDailyCost(config, date) - 0.0123) < 1e-10); |
| 209 | }); |
| 210 | |
| 211 | it('does NOT return cost recorded for a different date', () => { |
| 212 | const config = makeConfig('date-isolation'); |
| 213 | recordCallCost(config, 0.50, '2026-04-04'); // yesterday |
| 214 | assert.strictEqual(getDailyCost(config, '2026-04-05'), 0); |
| 215 | }); |
| 216 | |
| 217 | it('defaults to today UTC when no date argument is supplied', () => { |
| 218 | const config = makeConfig('today-default'); |
| 219 | const today = utcDateString(); |
| 220 | recordCallCost(config, 0.007, today); |
| 221 | assert(getDailyCost(config) > 0); |
| 222 | }); |
| 223 | |
| 224 | it('returns 0 for a corrupt cost file', () => { |
| 225 | const config = makeConfig('corrupt'); |
| 226 | fs.writeFileSync(getCostFilePath(config), 'NOT VALID JSON', 'utf8'); |
| 227 | assert.strictEqual(getDailyCost(config, '2026-04-05'), 0); |
| 228 | }); |
| 229 | |
| 230 | it('returns 0 when the file contains a non-numeric value for the date key', () => { |
| 231 | const config = makeConfig('non-numeric'); |
| 232 | fs.writeFileSync( |
| 233 | getCostFilePath(config), |
| 234 | JSON.stringify({ '2026-04-05': 'oops' }), |
| 235 | 'utf8', |
| 236 | ); |
| 237 | assert.strictEqual(getDailyCost(config, '2026-04-05'), 0); |
| 238 | }); |
| 239 | }); |
| 240 | |
| 241 | // ── 6. recordCallCost ───────────────────────────────────────────────────────── |
| 242 | |
| 243 | describe('recordCallCost', () => { |
| 244 | it('creates the cost file when it does not exist', () => { |
| 245 | const config = makeConfig('create-file'); |
| 246 | const filePath = getCostFilePath(config); |
| 247 | assert(!fs.existsSync(filePath)); |
| 248 | recordCallCost(config, 0.001, '2026-04-05'); |
| 249 | assert(fs.existsSync(filePath)); |
| 250 | }); |
| 251 | |
| 252 | it('creates parent directories as needed', () => { |
| 253 | const nested = path.join(tmpDir, 'deep', 'nested', 'data-dir'); |
| 254 | fs.mkdirSync(nested, { recursive: true }); |
| 255 | const config = { data_dir: nested }; |
| 256 | recordCallCost(config, 0.005, '2026-04-05'); |
| 257 | assert(fs.existsSync(getCostFilePath(config))); |
| 258 | }); |
| 259 | |
| 260 | it('accumulates cost across multiple calls on the same date', () => { |
| 261 | const config = makeConfig('accumulate'); |
| 262 | const date = '2026-04-05'; |
| 263 | recordCallCost(config, 0.010, date); |
| 264 | recordCallCost(config, 0.005, date); |
| 265 | recordCallCost(config, 0.003, date); |
| 266 | assert(Math.abs(getDailyCost(config, date) - 0.018) < 1e-10); |
| 267 | }); |
| 268 | |
| 269 | it('records costs for different dates independently', () => { |
| 270 | const config = makeConfig('multi-date'); |
| 271 | recordCallCost(config, 0.10, '2026-04-04'); |
| 272 | recordCallCost(config, 0.20, '2026-04-05'); |
| 273 | assert(Math.abs(getDailyCost(config, '2026-04-04') - 0.10) < 1e-10); |
| 274 | assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.20) < 1e-10); |
| 275 | }); |
| 276 | |
| 277 | it('ignores zero cost (no-op)', () => { |
| 278 | const config = makeConfig('zero-cost'); |
| 279 | recordCallCost(config, 0.05, '2026-04-05'); |
| 280 | recordCallCost(config, 0, '2026-04-05'); |
| 281 | assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.05) < 1e-10); |
| 282 | }); |
| 283 | |
| 284 | it('ignores negative cost (no-op)', () => { |
| 285 | const config = makeConfig('negative-cost'); |
| 286 | recordCallCost(config, 0.05, '2026-04-05'); |
| 287 | recordCallCost(config, -0.01, '2026-04-05'); |
| 288 | assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.05) < 1e-10); |
| 289 | }); |
| 290 | |
| 291 | it('ignores non-numeric cost (no-op)', () => { |
| 292 | const config = makeConfig('non-numeric-cost'); |
| 293 | recordCallCost(config, 0.05, '2026-04-05'); |
| 294 | recordCallCost(config, 'abc', '2026-04-05'); |
| 295 | assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.05) < 1e-10); |
| 296 | }); |
| 297 | |
| 298 | it('recovers gracefully from a corrupt cost file by starting fresh', () => { |
| 299 | const config = makeConfig('corrupt-recover'); |
| 300 | fs.writeFileSync(getCostFilePath(config), '{ broken json', 'utf8'); |
| 301 | assert.doesNotThrow(() => recordCallCost(config, 0.01, '2026-04-05')); |
| 302 | assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.01) < 1e-10); |
| 303 | }); |
| 304 | |
| 305 | it('defaults to today UTC when no date argument is supplied', () => { |
| 306 | const config = makeConfig('today-default-record'); |
| 307 | const today = utcDateString(); |
| 308 | recordCallCost(config, 0.042); |
| 309 | assert(Math.abs(getDailyCost(config, today) - 0.042) < 1e-10); |
| 310 | }); |
| 311 | }); |
| 312 | |
| 313 | // ── 7. resetDailyCost ───────────────────────────────────────────────────────── |
| 314 | |
| 315 | describe('resetDailyCost', () => { |
| 316 | it('writes an empty JSON object to the cost file', () => { |
| 317 | const config = makeConfig('reset-writes'); |
| 318 | recordCallCost(config, 0.99, '2026-04-05'); |
| 319 | resetDailyCost(config); |
| 320 | const raw = fs.readFileSync(getCostFilePath(config), 'utf8'); |
| 321 | assert.deepStrictEqual(JSON.parse(raw), {}); |
| 322 | }); |
| 323 | |
| 324 | it('getDailyCost returns 0 after reset', () => { |
| 325 | const config = makeConfig('reset-zero'); |
| 326 | recordCallCost(config, 0.123, '2026-04-05'); |
| 327 | resetDailyCost(config); |
| 328 | assert.strictEqual(getDailyCost(config, '2026-04-05'), 0); |
| 329 | }); |
| 330 | |
| 331 | it('creates the file if it does not exist', () => { |
| 332 | const config = makeConfig('reset-create'); |
| 333 | assert(!fs.existsSync(getCostFilePath(config))); |
| 334 | assert.doesNotThrow(() => resetDailyCost(config)); |
| 335 | assert(fs.existsSync(getCostFilePath(config))); |
| 336 | }); |
| 337 | |
| 338 | it('subsequent recordCallCost works correctly after reset', () => { |
| 339 | const config = makeConfig('reset-then-record'); |
| 340 | recordCallCost(config, 10.0, '2026-04-05'); |
| 341 | resetDailyCost(config); |
| 342 | recordCallCost(config, 0.007, '2026-04-05'); |
| 343 | assert(Math.abs(getDailyCost(config, '2026-04-05') - 0.007) < 1e-10); |
| 344 | }); |
| 345 | }); |
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