hub-consolidation.test.mjs
441 lines 17.0 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 import { describe, it } from 'node:test';
2 import assert from 'node:assert/strict';
3 import {
4 populateConsolSettingsForm,
5 buildConsolSettingsPayload,
6 renderConsolidationHistory,
7 formatCostMeter,
8 } from '../web/hub/consolidation-ui-logic.mjs';
9 import { normalizeBillingUser, defaultUserRecord } from '../hub/gateway/billing-logic.mjs';
10
11 describe('populateConsolSettingsForm', () => {
12 function makeForm() {
13 return {
14 'consol-interval': { value: '' },
15 'consol-idle-only': { checked: false },
16 'consol-idle-threshold': { value: '' },
17 'consol-run-on-start': { checked: false },
18 'pass-consolidate': { checked: false },
19 'pass-verify': { checked: false },
20 'pass-discover': { checked: false },
21 'consol-llm-provider': { value: '' },
22 'consol-llm-model': { value: '' },
23 'consol-llm-base-url': { value: '' },
24 'consol-cost-cap': { value: '' },
25 'consol-hosted-interval': { value: '120' },
26 'consol-lookback-hours': { value: '' },
27 'consol-max-events': { value: '' },
28 'consol-max-topics': { value: '' },
29 'consol-llm-max-tokens': { value: '' },
30 };
31 }
32
33 it('returns off for null settings', () => {
34 assert.equal(populateConsolSettingsForm(null, makeForm()), 'off');
35 });
36
37 it('returns off when daemon is missing', () => {
38 assert.equal(populateConsolSettingsForm({}, makeForm()), 'off');
39 });
40
41 it('returns daemon when daemon.enabled is true', () => {
42 const settings = { daemon: { enabled: true, interval_minutes: 60 } };
43 const form = makeForm();
44 assert.equal(populateConsolSettingsForm(settings, form), 'daemon');
45 assert.equal(form['consol-interval'].value, 60);
46 });
47
48 it('returns hosted when vault_path_display is canister', () => {
49 const settings = { daemon: { enabled: false }, vault_path_display: 'canister' };
50 assert.equal(populateConsolSettingsForm(settings, makeForm()), 'hosted');
51 });
52
53 it('returns hosted when hosted_delegating is true', () => {
54 const settings = { daemon: { enabled: false }, hosted_delegating: true };
55 assert.equal(populateConsolSettingsForm(settings, makeForm()), 'hosted');
56 });
57
58 it('syncs consol-hosted-interval when interval matches schedule options', () => {
59 const settings = {
60 daemon: { enabled: false, interval_minutes: 360 },
61 hosted_delegating: true,
62 };
63 const form = makeForm();
64 populateConsolSettingsForm(settings, form);
65 assert.equal(form['consol-hosted-interval'].value, '360');
66 });
67
68 it('populates all form fields from daemon config', () => {
69 const settings = {
70 daemon: {
71 enabled: true,
72 interval_minutes: 240,
73 idle_only: false,
74 idle_threshold_minutes: 30,
75 run_on_start: true,
76 max_cost_per_day_usd: 0.05,
77 passes: { consolidate: true, verify: false, discover: true },
78 lookback_hours: 48,
79 max_events_per_pass: 150,
80 max_topics_per_pass: 8,
81 llm: {
82 provider: 'openai',
83 model: 'gpt-4o-mini',
84 base_url: 'https://api.openai.com/v1',
85 max_tokens: 2048,
86 },
87 },
88 };
89 const form = makeForm();
90 populateConsolSettingsForm(settings, form);
91 assert.equal(form['consol-interval'].value, 240);
92 assert.equal(form['consol-idle-only'].checked, false);
93 assert.equal(form['consol-idle-threshold'].value, 30);
94 assert.equal(form['consol-run-on-start'].checked, true);
95 assert.equal(form['pass-consolidate'].checked, true);
96 assert.equal(form['pass-verify'].checked, false);
97 assert.equal(form['pass-discover'].checked, true);
98 assert.equal(form['consol-llm-provider'].value, 'openai');
99 assert.equal(form['consol-llm-model'].value, 'gpt-4o-mini');
100 assert.equal(form['consol-llm-base-url'].value, 'https://api.openai.com/v1');
101 assert.equal(form['consol-lookback-hours'].value, 48);
102 assert.equal(form['consol-max-events'].value, 150);
103 assert.equal(form['consol-max-topics'].value, 8);
104 assert.equal(form['consol-llm-max-tokens'].value, 2048);
105 assert.equal(form['consol-cost-cap'].value, 0.05);
106 });
107
108 it('uses defaults for missing daemon fields', () => {
109 const form = makeForm();
110 populateConsolSettingsForm({ daemon: {} }, form);
111 assert.equal(form['consol-interval'].value, 120);
112 assert.equal(form['consol-idle-only'].checked, true);
113 assert.equal(form['consol-idle-threshold'].value, 15);
114 assert.equal(form['consol-run-on-start'].checked, false);
115 assert.equal(form['pass-consolidate'].checked, true);
116 assert.equal(form['pass-verify'].checked, true);
117 assert.equal(form['pass-discover'].checked, false);
118 assert.equal(form['consol-cost-cap'].value, '');
119 assert.equal(form['consol-lookback-hours'].value, 24);
120 assert.equal(form['consol-max-events'].value, 200);
121 assert.equal(form['consol-max-topics'].value, 10);
122 assert.equal(form['consol-llm-max-tokens'].value, 1024);
123 });
124 });
125
126 describe('buildConsolSettingsPayload', () => {
127 function makeForm(overrides = {}) {
128 return {
129 'consol-interval': { value: '120' },
130 'consol-hosted-interval': { value: '120' },
131 'consol-idle-only': { checked: true },
132 'consol-idle-threshold': { value: '15' },
133 'consol-run-on-start': { checked: false },
134 'pass-consolidate': { checked: true },
135 'pass-verify': { checked: true },
136 'pass-discover': { checked: false },
137 'consol-llm-provider': { value: '' },
138 'consol-llm-model': { value: '' },
139 'consol-llm-base-url': { value: '' },
140 'consol-cost-cap': { value: '' },
141 'consol-lookback-hours': { value: '24' },
142 'consol-max-events': { value: '200' },
143 'consol-max-topics': { value: '10' },
144 'consol-llm-max-tokens': { value: '1024' },
145 ...overrides,
146 };
147 }
148
149 it('builds daemon payload', () => {
150 const payload = buildConsolSettingsPayload(makeForm(), 'daemon');
151 assert.equal(payload.mode, 'daemon');
152 assert.equal(payload.enabled, true);
153 assert.equal(payload.interval_minutes, 120);
154 assert.equal(payload.idle_only, true);
155 assert.equal(payload.idle_threshold_minutes, 15);
156 assert.equal(payload.run_on_start, false);
157 assert.deepEqual(payload.passes, { consolidate: true, verify: true, discover: false });
158 assert.deepEqual(payload.llm, { provider: '', model: '', base_url: '', max_tokens: 1024 });
159 assert.equal(payload.lookback_hours, 24);
160 assert.equal(payload.max_events_per_pass, 200);
161 assert.equal(payload.max_topics_per_pass, 10);
162 assert.equal(payload.max_cost_per_day_usd, null);
163 });
164
165 it('builds off payload', () => {
166 const payload = buildConsolSettingsPayload(makeForm(), 'off');
167 assert.equal(payload.mode, 'off');
168 assert.equal(payload.enabled, false);
169 });
170
171 it('builds hosted payload with advanced knobs for gateway billing persistence', () => {
172 const payload = buildConsolSettingsPayload(makeForm(), 'hosted');
173 assert.equal(payload.mode, 'hosted');
174 assert.equal(payload.enabled, false);
175 assert.equal(payload.lookback_hours, 24);
176 assert.equal(payload.max_events_per_pass, 200);
177 assert.equal(payload.max_topics_per_pass, 10);
178 assert.equal(payload.llm.max_tokens, 1024);
179 });
180
181 it('uses consol-hosted-interval for interval_minutes when mode is hosted', () => {
182 const payload = buildConsolSettingsPayload(
183 makeForm({ 'consol-hosted-interval': { value: '360' }, 'consol-interval': { value: '120' } }),
184 'hosted',
185 );
186 assert.equal(payload.interval_minutes, 360);
187 });
188
189 it('includes cost cap when set', () => {
190 const form = makeForm({ 'consol-cost-cap': { value: '0.10' } });
191 const payload = buildConsolSettingsPayload(form, 'daemon');
192 assert.equal(payload.max_cost_per_day_usd, 0.10);
193 });
194
195 it('null cost cap when value is empty', () => {
196 const form = makeForm({ 'consol-cost-cap': { value: '' } });
197 const payload = buildConsolSettingsPayload(form, 'daemon');
198 assert.equal(payload.max_cost_per_day_usd, null);
199 });
200
201 it('includes LLM overrides', () => {
202 const form = makeForm({
203 'consol-llm-provider': { value: 'ollama' },
204 'consol-llm-model': { value: 'llama3' },
205 'consol-llm-base-url': { value: 'http://localhost:11434' },
206 'consol-llm-max-tokens': { value: '2048' },
207 });
208 const payload = buildConsolSettingsPayload(form, 'daemon');
209 assert.deepEqual(payload.llm, {
210 provider: 'ollama',
211 model: 'llama3',
212 base_url: 'http://localhost:11434',
213 max_tokens: 2048,
214 });
215 });
216
217 it('clamps advanced daemon fields to server ranges', () => {
218 const form = makeForm({
219 'consol-lookback-hours': { value: '99999' },
220 'consol-max-events': { value: '50000' },
221 'consol-max-topics': { value: '900' },
222 'consol-llm-max-tokens': { value: '100000' },
223 });
224 const payload = buildConsolSettingsPayload(form, 'daemon');
225 assert.equal(payload.lookback_hours, 8760);
226 assert.equal(payload.max_events_per_pass, 10000);
227 assert.equal(payload.max_topics_per_pass, 500);
228 assert.equal(payload.llm.max_tokens, 8192);
229 });
230
231 it('clamps interval_minutes to at least 1', () => {
232 const form = makeForm({ 'consol-interval': { value: '-5' } });
233 const payload = buildConsolSettingsPayload(form, 'daemon');
234 assert.equal(payload.interval_minutes, 1);
235 });
236
237 it('floors non-integer interval', () => {
238 const form = makeForm({ 'consol-interval': { value: '123.7' } });
239 const payload = buildConsolSettingsPayload(form, 'daemon');
240 assert.equal(payload.interval_minutes, 123);
241 });
242 });
243
244 describe('renderConsolidationHistory', () => {
245 function makeContainer() {
246 return { innerHTML: '' };
247 }
248
249 it('renders empty message for no events', () => {
250 const c = makeContainer();
251 const count = renderConsolidationHistory([], c);
252 assert.equal(count, 0);
253 assert.ok(c.innerHTML.includes('No consolidation history'));
254 });
255
256 it('renders empty message for null events', () => {
257 const c = makeContainer();
258 const count = renderConsolidationHistory(null, c);
259 assert.equal(count, 0);
260 });
261
262 it('renders correct number of rows (ts field from real memory events)', () => {
263 const events = [
264 { ts: '2026-04-01T10:00:00Z', data: { topics_count: 3, total_events: 15, cost_usd: 0.004 } },
265 { ts: '2026-04-02T10:00:00Z', data: { topics_count: 5, total_events: 22, cost_usd: 0.007, dry_run: true } },
266 { ts: '2026-04-03T10:00:00Z', data: { topics_count: 2, total_events: 8, cost_usd: 0.003, error: 'LLM timeout' } },
267 ];
268 const c = makeContainer();
269 const count = renderConsolidationHistory(events, c);
270 assert.equal(count, 3);
271 assert.ok(c.innerHTML.includes('<table'));
272 assert.ok(c.innerHTML.includes('</table>'));
273 const trCount = (c.innerHTML.match(/<tr>/g) || []).length;
274 assert.equal(trCount, 4); // 1 header + 3 data rows
275 });
276
277 it('renders date using legacy timestamp field as fallback', () => {
278 const events = [{ timestamp: '2026-04-01T10:00:00Z', data: { topics_count: 1 } }];
279 const c = makeContainer();
280 renderConsolidationHistory(events, c);
281 // The date cell must contain a human-readable date, not '—'.
282 // Extract just the first <td> value from the rendered HTML.
283 const firstTd = c.innerHTML.match(/<td>([^<]*)<\/td>/);
284 assert.ok(firstTd && firstTd[1] !== '—', 'date cell should not be — when timestamp is present');
285 });
286
287 it('shows events merged from event_count fallback (per-topic shape)', () => {
288 const events = [{ ts: '2026-04-01T10:00:00Z', data: { topic: 'AI', event_count: 7 } }];
289 const c = makeContainer();
290 renderConsolidationHistory(events, c);
291 assert.ok(c.innerHTML.includes('7'), 'event_count should render in Events Merged column');
292 });
293
294 it('handles topics_count as array (legacy malformed events) by rendering .length', () => {
295 const topicsArray = [{ topic: 'AI' }, { topic: 'UX' }, { topic: 'Security' }];
296 const events = [{ ts: '2026-04-01T10:00:00Z', data: { topics_count: topicsArray, total_events: 10 } }];
297 const c = makeContainer();
298 renderConsolidationHistory(events, c);
299 assert.ok(c.innerHTML.includes('3'), 'array topics_count should render as its length (3)');
300 assert.ok(!c.innerHTML.includes('[object Object]'), 'should not render [object Object]');
301 });
302
303 it('shows dry-run status', () => {
304 const events = [{ ts: '2026-04-01T10:00:00Z', data: { dry_run: true } }];
305 const c = makeContainer();
306 renderConsolidationHistory(events, c);
307 assert.ok(c.innerHTML.includes('dry-run'));
308 });
309
310 it('shows error status', () => {
311 const events = [{ ts: '2026-04-01T10:00:00Z', data: { error: 'fail' } }];
312 const c = makeContainer();
313 renderConsolidationHistory(events, c);
314 assert.ok(c.innerHTML.includes('error'));
315 });
316
317 it('shows complete status for normal events', () => {
318 const events = [{ ts: '2026-04-01T10:00:00Z', data: { topics_count: 1 } }];
319 const c = makeContainer();
320 renderConsolidationHistory(events, c);
321 assert.ok(c.innerHTML.includes('complete'));
322 });
323
324 it('returns 0 for null container', () => {
325 assert.equal(renderConsolidationHistory([{ data: {} }], null), 0);
326 });
327
328 it('escapes HTML in event data', () => {
329 const events = [{ ts: '2026-04-01T10:00:00Z', data: { topics_count: '<script>alert(1)</script>' } }];
330 const c = makeContainer();
331 renderConsolidationHistory(events, c);
332 assert.ok(!c.innerHTML.includes('<script>'));
333 assert.ok(c.innerHTML.includes('&lt;script&gt;'));
334 });
335 });
336
337 describe('formatCostMeter', () => {
338 it('returns no meter when cap is null', () => {
339 const r = formatCostMeter(0.005, null);
340 assert.equal(r.showMeter, false);
341 assert.equal(r.fillPercent, 0);
342 assert.equal(r.display, '$0.005 today');
343 assert.equal(r.capLabel, '');
344 });
345
346 it('returns no meter when cap is 0', () => {
347 const r = formatCostMeter(0.003, 0);
348 assert.equal(r.showMeter, false);
349 });
350
351 it('calculates fill percent correctly', () => {
352 const r = formatCostMeter(0.025, 0.05);
353 assert.equal(r.showMeter, true);
354 assert.equal(r.fillPercent, 50);
355 assert.equal(r.display, '$0.025 today');
356 assert.equal(r.capLabel, 'cap: $0.05');
357 });
358
359 it('caps fill percent at 100', () => {
360 const r = formatCostMeter(0.10, 0.05);
361 assert.equal(r.fillPercent, 100);
362 assert.equal(r.showMeter, true);
363 });
364
365 it('handles zero cost with cap', () => {
366 const r = formatCostMeter(0, 0.05);
367 assert.equal(r.fillPercent, 0);
368 assert.equal(r.showMeter, true);
369 assert.equal(r.display, '$0.000 today');
370 });
371
372 it('handles negative cost gracefully', () => {
373 const r = formatCostMeter(-1, 0.05);
374 assert.equal(r.fillPercent, 0);
375 assert.equal(r.display, '$0.000 today');
376 });
377
378 it('handles NaN cost gracefully', () => {
379 const r = formatCostMeter(NaN, 0.10);
380 assert.equal(r.fillPercent, 0);
381 assert.equal(r.display, '$0.000 today');
382 });
383
384 it('handles undefined inputs', () => {
385 const r = formatCostMeter(undefined, undefined);
386 assert.equal(r.showMeter, false);
387 assert.equal(r.display, '$0.000 today');
388 });
389 });
390
391 describe('gateway consolidation settings — billing store logic', () => {
392 it('defaultUserRecord includes consolidation_passes', () => {
393 const u = defaultUserRecord('test-user');
394 assert.deepEqual(u.consolidation_passes, { consolidate: true, verify: true, discover: false });
395 assert.equal(u.consolidation_enabled, false);
396 assert.equal(u.consolidation_interval_minutes, null);
397 assert.equal(u.consolidation_lookback_hours, 24);
398 assert.equal(u.consolidation_max_events_per_pass, 200);
399 assert.equal(u.consolidation_max_topics_per_pass, 10);
400 assert.equal(u.consolidation_llm_max_tokens, 1024);
401 });
402
403 it('normalizeBillingUser adds consolidation_passes when missing', () => {
404 const u = normalizeBillingUser({ user_id: 'x' });
405 assert.deepEqual(u.consolidation_passes, { consolidate: true, verify: true, discover: false });
406 });
407
408 it('normalizeBillingUser preserves existing consolidation_passes', () => {
409 const u = normalizeBillingUser({
410 user_id: 'x',
411 consolidation_passes: { consolidate: false, verify: true, discover: true },
412 });
413 assert.deepEqual(u.consolidation_passes, { consolidate: false, verify: true, discover: true });
414 });
415
416 it('normalizeBillingUser sets consolidation_enabled=false when undefined', () => {
417 const u = normalizeBillingUser({ user_id: 'x' });
418 assert.equal(u.consolidation_enabled, false);
419 });
420
421 it('buildConsolSettingsPayload mode=hosted sends mode field for gateway to distinguish from off', () => {
422 const form = {
423 'consol-interval': { value: '120' },
424 'consol-idle-only': { checked: true },
425 'consol-idle-threshold': { value: '15' },
426 'consol-run-on-start': { checked: false },
427 'pass-consolidate': { checked: true },
428 'pass-verify': { checked: true },
429 'pass-discover': { checked: false },
430 'consol-llm-provider': { value: '' },
431 'consol-llm-model': { value: '' },
432 'consol-llm-base-url': { value: '' },
433 'consol-cost-cap': { value: '' },
434 };
435 const hosted = buildConsolSettingsPayload(form, 'hosted');
436 const off = buildConsolSettingsPayload(form, 'off');
437 assert.equal(hosted.mode, 'hosted');
438 assert.equal(off.mode, 'off');
439 assert.notEqual(hosted.mode, off.mode, 'hosted and off must be distinguishable');
440 });
441 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago