memory-graph.test.ts
1 import { describe, it } from 'node:test' 2 import assert from 'node:assert' 3 import { 4 normalizeLinkedMemoryIds, 5 normalizeMemoryLookupLimits, 6 resolveLookupRequest, 7 traverseLinkedMemoryGraph, 8 } from '@/lib/server/memory/memory-graph' 9 import type { MemoryLookupLimits, LinkedMemoryNode } from '@/lib/server/memory/memory-graph' 10 11 describe('normalizeLinkedMemoryIds', () => { 12 it('filters empty strings and self-references', () => { 13 assert.deepStrictEqual( 14 normalizeLinkedMemoryIds(['a', '', 'b', ' ', 'a', 'c'], 'self'), 15 ['a', 'b', 'c'] 16 ) 17 }) 18 19 it('returns empty array for non-array input', () => { 20 assert.deepStrictEqual(normalizeLinkedMemoryIds(null), []) 21 assert.deepStrictEqual(normalizeLinkedMemoryIds(undefined), []) 22 assert.deepStrictEqual(normalizeLinkedMemoryIds('not-an-array'), []) 23 }) 24 25 it('deduplicates ids', () => { 26 assert.deepStrictEqual( 27 normalizeLinkedMemoryIds(['a', 'b', 'a', 'a', 'c'], undefined), 28 ['a', 'b', 'c'] 29 ) 30 }) 31 }) 32 33 describe('normalizeMemoryLookupLimits', () => { 34 it('returns defaults for empty settings', () => { 35 const limits = normalizeMemoryLookupLimits({}) 36 assert.strictEqual(limits.maxDepth, 3) 37 assert.strictEqual(limits.maxPerLookup, 20) 38 assert.strictEqual(limits.maxLinkedExpansion, 60) 39 }) 40 41 it('clamps to valid ranges', () => { 42 const limits = normalizeMemoryLookupLimits({ 43 memoryReferenceDepth: 100, 44 maxMemoriesPerLookup: 1000, 45 maxLinkedMemoriesExpanded: 5000, 46 }) 47 assert.strictEqual(limits.maxDepth, 12) // max 48 assert.strictEqual(limits.maxPerLookup, 200) // max 49 assert.strictEqual(limits.maxLinkedExpansion, 1000) // max 50 }) 51 52 it('allows zeros for depth and linked expansion', () => { 53 const limits = normalizeMemoryLookupLimits({ 54 memoryReferenceDepth: 0, 55 maxMemoriesPerLookup: 5, 56 maxLinkedMemoriesExpanded: 0, 57 }) 58 assert.strictEqual(limits.maxDepth, 0) 59 assert.strictEqual(limits.maxPerLookup, 5) 60 assert.strictEqual(limits.maxLinkedExpansion, 0) 61 }) 62 }) 63 64 describe('resolveLookupRequest', () => { 65 const defaults: MemoryLookupLimits = { 66 maxDepth: 3, 67 maxPerLookup: 20, 68 maxLinkedExpansion: 60, 69 } 70 71 it('uses defaults for empty request', () => { 72 assert.deepStrictEqual(resolveLookupRequest(defaults, {}), defaults) 73 }) 74 75 it('overrides with request values', () => { 76 const result = resolveLookupRequest(defaults, { depth: 2, limit: 10, linkedLimit: 30 }) 77 assert.strictEqual(result.maxDepth, 2) 78 assert.strictEqual(result.maxPerLookup, 10) 79 assert.strictEqual(result.maxLinkedExpansion, 30) 80 }) 81 82 it('caps at defaults maxima', () => { 83 const result = resolveLookupRequest(defaults, { depth: 100, limit: 1000, linkedLimit: 5000 }) 84 assert.strictEqual(result.maxDepth, 3) 85 assert.strictEqual(result.maxPerLookup, 20) 86 assert.strictEqual(result.maxLinkedExpansion, 60) 87 }) 88 }) 89 90 describe('traverseLinkedMemoryGraph', () => { 91 const fetchByIds = (ids: string[]): LinkedMemoryNode[] => { 92 return ids.map((id) => ({ 93 id, 94 linkedMemoryIds: id === 'a' ? ['b', 'c'] : id === 'b' ? ['d'] : [], 95 })) 96 } 97 98 it('returns empty for empty seeds', () => { 99 const result = traverseLinkedMemoryGraph([], { maxDepth: 3, maxPerLookup: 20, maxLinkedExpansion: 60 }, fetchByIds) 100 assert.strictEqual(result.entries.length, 0) 101 assert.strictEqual(result.truncated, false) 102 assert.strictEqual(result.expandedLinkedCount, 0) 103 }) 104 105 it('traverses linked nodes by depth', () => { 106 const seeds = [{ id: 'a', linkedMemoryIds: ['b', 'c'] }] 107 const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 2, maxPerLookup: 20, maxLinkedExpansion: 60 }, fetchByIds) 108 const ids = result.entries.map((n) => n.id) 109 assert.ok(ids.includes('a')) 110 assert.ok(ids.includes('b')) 111 assert.ok(ids.includes('c')) 112 assert.ok(ids.includes('d')) // depth 2 113 assert.strictEqual(result.expandedLinkedCount, 3) // b, c, d 114 }) 115 116 it('respects maxDepth', () => { 117 const limitedFetch = (ids: string[]): LinkedMemoryNode[] => { 118 const map: Record<string, string[]> = { a: ['b'], b: ['c'], c: ['d'], d: [] } 119 return ids.map((id) => ({ id, linkedMemoryIds: map[id] || [] })) 120 } 121 const seeds = [{ id: 'a', linkedMemoryIds: ['b'] }] 122 const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 1, maxPerLookup: 20, maxLinkedExpansion: 60 }, limitedFetch) 123 const ids = result.entries.map((n) => n.id) 124 assert.ok(ids.includes('a')) 125 assert.ok(ids.includes('b')) 126 assert.ok(!ids.includes('c')) // depth 1 stops before c 127 }) 128 129 it('respects maxPerLookup', () => { 130 const seeds = [{ id: 'a', linkedMemoryIds: ['b', 'c', 'd', 'e', 'f'] }] 131 const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 3, maxPerLookup: 3, maxLinkedExpansion: 60 }, fetchByIds) 132 assert.strictEqual(result.entries.length, 3) 133 assert.strictEqual(result.truncated, true) 134 }) 135 136 it('respects maxLinkedExpansion', () => { 137 const seeds = [{ id: 'a', linkedMemoryIds: ['b', 'c', 'd', 'e', 'f'] }] 138 const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 3, maxPerLookup: 20, maxLinkedExpansion: 2 }, fetchByIds) 139 assert.strictEqual(result.expandedLinkedCount, 2) 140 assert.strictEqual(result.truncated, true) 141 }) 142 143 it('handles circular links', () => { 144 const circularFetch = (ids: string[]): LinkedMemoryNode[] => { 145 const map: Record<string, string[]> = { a: ['b'], b: ['a'] } 146 return ids.map((id) => ({ id, linkedMemoryIds: map[id] || [] })) 147 } 148 const seeds = [{ id: 'a', linkedMemoryIds: ['b'] }] 149 const result = traverseLinkedMemoryGraph(seeds, { maxDepth: 10, maxPerLookup: 100, maxLinkedExpansion: 100 }, circularFetch) 150 assert.strictEqual(result.entries.length, 2) // just a and b 151 assert.strictEqual(result.truncated, false) 152 }) 153 })