// Session summarization — tests the REAL maybeSummarize decision logic against // mocked memory + Ollama endpoints. Guards the refactor that moved data-fetching // into the summarizer (episode-stats + episodes/since) instead of receiving the // whole session. Constants: THRESHOLD_TOKENS=200, MIN_EPISODES_SINCE=5, MAX_SUMMARY_TOKENS=800. const { test } = require('node:test'); const assert = require('node:assert'); const { maybeSummarize } = require('../packages/orchestration-service/src/services/summarization'); const json = (obj) => ({ ok: true, status: 200, json: async () => obj }); // Route mocked fetches by URL and record what got written. function mockMemory({ stats, summaries, since }) { const calls = { posted: null, patched: null, sinceAfterId: null }; global.fetch = async (url, opts = {}) => { const u = String(url); if (u.endsWith('/episode-stats')) return json(stats); if (u.endsWith('/summaries') && (!opts.method || opts.method === 'GET')) return json(summaries); if (u.includes('/episodes/since/')) { calls.sinceAfterId = Number(u.split('/since/').at(-1)); return json(since.filter(ep => ep.id > calls.sinceAfterId)); } if (u.endsWith('/utility/complete')) return json({ text: 'A concise third-person summary.' }); if (u.endsWith('/summaries') && opts.method === 'POST') { calls.posted = JSON.parse(opts.body); return json({ id: 99 }); } if (/\/summaries\/\d+$/.test(u) && opts.method === 'PATCH') { calls.patched = JSON.parse(opts.body); return json({ ok: true }); } throw new Error('unexpected fetch: ' + u); }; return calls; } const eps = (n, startId = 1, tok = 50) => Array.from({ length: n }, (_, i) => ({ id: startId + i, user_message: `u${startId + i}`, ai_response: `a${startId + i}`, token_count: tok })); test('under the token threshold → nothing is written', async () => { const c = mockMemory({ stats: { totalTokens: 150, count: 3, maxId: 3 }, summaries: [], since: eps(3) }); await maybeSummarize({ id: 1 }); assert.strictEqual(c.posted, null); assert.strictEqual(c.patched, null); }); test('over threshold, no prior summary → POST covering all episodes', async () => { const c = mockMemory({ stats: { totalTokens: 300, count: 6, maxId: 6 }, summaries: [], since: eps(6) }); await maybeSummarize({ id: 2 }); assert.ok(c.posted && !c.patched, 'creates via POST'); assert.strictEqual(c.sinceAfterId, 0, 'fetches since/0 = whole session when no summary exists'); assert.strictEqual(c.posted.episodeRange, '1-6'); assert.strictEqual(c.posted.tokenCount, 300, 'token count comes from the stats aggregate'); }); test('over threshold, small prior summary, ≥5 new → PATCH the tail only', async () => { const c = mockMemory({ stats: { totalTokens: 550, count: 11, maxId: 11 }, summaries: [{ id: 42, content: 'short summary', episode_range: '1-6' }], since: eps(11), }); await maybeSummarize({ id: 3 }); assert.ok(c.patched && !c.posted, 'updates via PATCH'); assert.strictEqual(c.sinceAfterId, 6, 'only fetches the un-summarized tail'); assert.strictEqual(c.patched.episodeRange, '7-11'); }); test('the MIN_EPISODES_SINCE guard blocks re-summarizing too soon', async () => { const c = mockMemory({ stats: { totalTokens: 450, count: 9, maxId: 9 }, summaries: [{ id: 43, content: 'short', episode_range: '1-6' }], since: eps(9), // only 3 new (7,8,9) < 5 }); await maybeSummarize({ id: 4 }); assert.strictEqual(c.posted, null); assert.strictEqual(c.patched, null); }); test('a large existing summary forces a fresh row instead of appending', async () => { const c = mockMemory({ stats: { totalTokens: 900, count: 15, maxId: 15 }, summaries: [{ id: 44, content: 'x'.repeat(850), episode_range: '1-6' }], // > MAX_SUMMARY_TOKENS since: eps(15), }); await maybeSummarize({ id: 5 }); assert.ok(c.posted && !c.patched, 'large summary → POST fresh row'); });