memory-integration.test.ts
1 import { after, before, describe, it } from 'node:test' 2 import assert from 'node:assert/strict' 3 import fs from 'node:fs' 4 import os from 'node:os' 5 import path from 'node:path' 6 import type { Session } from '@/types' 7 8 const originalEnv = { 9 DATA_DIR: process.env.DATA_DIR, 10 WORKSPACE_DIR: process.env.WORKSPACE_DIR, 11 SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE, 12 } 13 14 let tempDir = '' 15 let memDb: ReturnType<Awaited<typeof import('@/lib/server/memory/memory-db')>['getMemoryDb']> 16 let executeMemoryAction: Awaited<typeof import('@/lib/server/session-tools/memory')>['executeMemoryAction'] 17 let memoryPolicy: typeof import('@/lib/server/memory/memory-policy') 18 19 before(async () => { 20 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-memory-int-')) 21 process.env.DATA_DIR = path.join(tempDir, 'data') 22 process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace') 23 process.env.SWARMCLAW_BUILD_MODE = '1' 24 fs.mkdirSync(process.env.DATA_DIR, { recursive: true }) 25 fs.mkdirSync(process.env.WORKSPACE_DIR, { recursive: true }) 26 27 const memDbMod = await import('@/lib/server/memory/memory-db') 28 memDb = memDbMod.getMemoryDb() 29 30 const memoryMod = await import('@/lib/server/session-tools/memory') 31 executeMemoryAction = memoryMod.executeMemoryAction 32 33 memoryPolicy = await import('@/lib/server/memory/memory-policy') 34 }) 35 36 after(() => { 37 if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR 38 else process.env.DATA_DIR = originalEnv.DATA_DIR 39 if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR 40 else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR 41 if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE 42 else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE 43 fs.rmSync(tempDir, { recursive: true, force: true }) 44 }) 45 46 // ─── Memory CRUD Lifecycle ────────────────────────────────────────── 47 48 describe('Memory CRUD lifecycle via executeMemoryAction', () => { 49 let storedId = '' 50 51 it('stores a memory and returns confirmation', async () => { 52 const result = await executeMemoryAction( 53 { action: 'store', key: 'test-crud', value: 'CRUD content', category: 'note' }, 54 { agentId: 'agent-crud' }, 55 ) 56 assert.match(String(result), /Stored memory/) 57 const idMatch = String(result).match(/\(id: ([^)]+)\)/) 58 assert.ok(idMatch, 'result should contain an id') 59 storedId = idMatch[1] 60 }) 61 62 it('gets the stored memory by id', async () => { 63 const result = await executeMemoryAction( 64 { action: 'get', id: storedId }, 65 { agentId: 'agent-crud' }, 66 ) 67 assert.match(String(result), /test-crud/) 68 assert.match(String(result), /CRUD content/) 69 }) 70 71 it('searches for the memory by query', async () => { 72 const result = await executeMemoryAction( 73 { action: 'search', query: 'CRUD content' }, 74 { agentId: 'agent-crud' }, 75 ) 76 assert.match(String(result), /CRUD content/) 77 }) 78 79 it('lists all memories and includes it', async () => { 80 const result = await executeMemoryAction( 81 { action: 'list' }, 82 { agentId: 'agent-crud' }, 83 ) 84 assert.match(String(result), /test-crud/) 85 }) 86 87 it('updates title and content', async () => { 88 const result = await executeMemoryAction( 89 { action: 'update', id: storedId, title: 'updated-title', value: 'updated-content' }, 90 { agentId: 'agent-crud' }, 91 ) 92 assert.match(String(result), /Updated memory/) 93 assert.match(String(result), /updated-title/) 94 }) 95 96 it('deletes and confirms gone', async () => { 97 const deleteResult = await executeMemoryAction( 98 { action: 'delete', id: storedId }, 99 { agentId: 'agent-crud' }, 100 ) 101 assert.match(String(deleteResult), /Deleted/) 102 const getResult = await executeMemoryAction( 103 { action: 'get', id: storedId }, 104 { agentId: 'agent-crud' }, 105 ) 106 assert.match(String(getResult), /not found|access denied/i) 107 }) 108 109 it('falls back to the latest user fact when store omits value', async () => { 110 const sessionContext: Partial<Session> = { 111 id: 'session-implicit', 112 name: 'Implicit store', 113 agentId: 'agent-crud', 114 messages: [ 115 { role: 'user', text: 'Remember this exactly: Project Kodiak uses amber-fox and the freeze date is April 21, 2026.', time: Date.now() }, 116 ], 117 } 118 const result = await executeMemoryAction( 119 { action: 'store', key: 'implicit-fact-store' }, 120 sessionContext, 121 ) 122 assert.match(String(result), /Stored memory/) 123 124 const search = await executeMemoryAction( 125 { action: 'search', query: 'amber-fox April 21 2026' }, 126 { agentId: 'agent-crud' }, 127 ) 128 assert.match(String(search), /April 21, 2026/) 129 assert.match(String(search), /amber-fox/) 130 }) 131 }) 132 133 // ─── Memory Linking & Graph ───────────────────────────────────────── 134 135 describe('Memory linking and graph', () => { 136 let idA = '' 137 let idB = '' 138 let idC = '' 139 140 before(async () => { 141 const a = memDb.add({ agentId: 'agent-link', category: 'note', title: 'Node A', content: 'alpha unique content' }) 142 const b = memDb.add({ agentId: 'agent-link', category: 'note', title: 'Node B', content: 'beta unique content' }) 143 const c = memDb.add({ agentId: 'agent-link', category: 'note', title: 'Node C', content: 'gamma unique content' }) 144 idA = a.id 145 idB = b.id 146 idC = c.id 147 }) 148 149 it('links A→B and B→C with bidirectional links', () => { 150 memDb.link(idA, [idB], true) 151 memDb.link(idB, [idC], true) 152 153 const a = memDb.get(idA)! 154 const b = memDb.get(idB)! 155 const c = memDb.get(idC)! 156 157 assert.ok(a.linkedMemoryIds?.includes(idB), 'A should link to B') 158 assert.ok(b.linkedMemoryIds?.includes(idA), 'B should link back to A') 159 assert.ok(b.linkedMemoryIds?.includes(idC), 'B should link to C') 160 assert.ok(c.linkedMemoryIds?.includes(idB), 'C should link back to B') 161 }) 162 163 it('unlinks A→B bidirectionally', () => { 164 memDb.unlink(idA, [idB], true) 165 166 const a = memDb.get(idA)! 167 const b = memDb.get(idB)! 168 169 const aLinks = a.linkedMemoryIds || [] 170 const bLinks = b.linkedMemoryIds || [] 171 assert.ok(!aLinks.includes(idB), 'A should no longer link to B') 172 assert.ok(!bLinks.includes(idA), 'B should no longer link to A') 173 // B↔C should still exist 174 assert.ok(bLinks.includes(idC), 'B should still link to C') 175 }) 176 177 it('deleting C cleans up B linkedMemoryIds', () => { 178 memDb.delete(idC) 179 const b = memDb.get(idB)! 180 const bLinks = b.linkedMemoryIds || [] 181 assert.ok(!bLinks.includes(idC), 'B should no longer reference deleted C') 182 }) 183 }) 184 185 // ─── Scope Filtering ──────────────────────────────────────────────── 186 187 describe('Scope filtering', () => { 188 before(() => { 189 memDb.add({ agentId: 'agent-a', category: 'note', title: 'A-only', content: 'scope test agent a' }) 190 memDb.add({ agentId: 'agent-b', category: 'note', title: 'B-only', content: 'scope test agent b' }) 191 memDb.add({ agentId: null, category: 'note', title: 'Shared global', content: 'scope test global' }) 192 memDb.add({ agentId: 'agent-c', category: 'note', title: 'Shared with B', content: 'scope shared with b', sharedWith: ['agent-b'] }) 193 }) 194 195 it('agent scope shows only that agent memories', async () => { 196 const result = await executeMemoryAction( 197 { action: 'list', scope: 'agent' }, 198 { agentId: 'agent-a' }, 199 ) 200 assert.match(String(result), /A-only/) 201 assert.doesNotMatch(String(result), /B-only/) 202 }) 203 204 it('global scope shows only shared memories (no agentId)', async () => { 205 const result = await executeMemoryAction( 206 { action: 'list', scope: 'global' }, 207 { agentId: 'agent-a' }, 208 ) 209 assert.match(String(result), /Shared global/) 210 assert.doesNotMatch(String(result), /A-only/) 211 }) 212 213 it('sharedWith memories visible to target agent in agent scope', async () => { 214 const result = await executeMemoryAction( 215 { action: 'list', scope: 'agent' }, 216 { agentId: 'agent-b' }, 217 ) 218 assert.match(String(result), /Shared with B/) 219 }) 220 }) 221 222 describe('Search source filtering', () => { 223 before(() => { 224 memDb.add({ 225 agentId: 'agent-source-filter', 226 category: 'projects/decisions', 227 title: 'Kodiak durable fact', 228 content: 'Project Kodiak uses amber-fox and the freeze date is April 21, 2026.', 229 }) 230 memDb.add({ 231 agentId: 'agent-source-filter', 232 sessionId: 'archive-session-1', 233 category: 'session_archive', 234 title: 'Session archive: kodiak stale', 235 content: 'Transcript excerpt: Project Kodiak freeze date was April 18, 2026.', 236 metadata: { tier: 'archive' }, 237 }) 238 memDb.add({ 239 agentId: 'agent-source-filter', 240 category: 'operations/execution', 241 title: 'Auto execution note', 242 content: 'assistant_outcome: during a previous run I mentioned April 18, 2026 while fixing Project Kodiak memory.', 243 }) 244 }) 245 246 it('search defaults to durable memories', async () => { 247 const result = await executeMemoryAction( 248 { action: 'search', query: 'Project Kodiak amber-fox freeze date' }, 249 { agentId: 'agent-source-filter', sessionId: 'agent-source-filter', messages: [] }, 250 ) 251 assert.match(String(result), /Kodiak durable fact/) 252 assert.doesNotMatch(String(result), /Session archive: kodiak stale/) 253 assert.doesNotMatch(String(result), /Auto execution note/) 254 }) 255 256 it('search can explicitly include archives and working memories', async () => { 257 const archiveResult = await executeMemoryAction( 258 { action: 'search', query: 'Project Kodiak freeze date', sources: ['durable', 'archive', 'working'] }, 259 { agentId: 'agent-source-filter', sessionId: 'archive-session-1', messages: [] }, 260 ) 261 assert.match(String(archiveResult), /Kodiak durable fact/) 262 assert.match(String(archiveResult), /Session archive: kodiak stale/) 263 264 const workingResult = await executeMemoryAction( 265 { action: 'search', query: 'assistant_outcome previous run April 18, 2026', sources: ['working'] }, 266 { agentId: 'agent-source-filter', sessionId: 'archive-session-1', messages: [] }, 267 ) 268 assert.match(String(workingResult), /Auto execution note/) 269 }) 270 }) 271 272 describe('Canonical memory correction', () => { 273 it('update without an explicit id resolves and corrects the canonical durable memory', async () => { 274 const stale = memDb.add({ 275 agentId: 'agent-canonical', 276 category: 'projects/decisions', 277 title: 'Project Kodiak codename and freeze date', 278 content: 'Project Kodiak uses the codename "amber-fox" and the freeze date is April 18, 2026.', 279 }) 280 memDb.add({ 281 agentId: 'agent-canonical', 282 category: 'note', 283 title: '[auto-consolidated] Project Kodiak note', 284 content: 'Stored earlier: Project Kodiak codename amber-fox freeze date April 18, 2026.', 285 }) 286 287 const result = await executeMemoryAction( 288 { 289 action: 'update', 290 title: 'Project Kodiak freeze date correction', 291 value: 'Project Kodiak uses the codename "amber-fox" and the freeze date is April 21, 2026.', 292 }, 293 { agentId: 'agent-canonical', sessionId: 'agent-canonical', messages: [] }, 294 ) 295 296 assert.match(String(result), /Updated memory/) 297 const corrected = memDb.get(stale.id) 298 assert.ok(corrected) 299 assert.match(String(corrected?.content), /April 21, 2026/) 300 301 const recall = await executeMemoryAction( 302 { action: 'search', query: 'Project Kodiak amber-fox freeze date' }, 303 { agentId: 'agent-canonical', sessionId: 'agent-canonical', messages: [] }, 304 ) 305 assert.match(String(recall), /April 21, 2026/) 306 assert.doesNotMatch(String(recall), /auto-consolidated/i) 307 }) 308 309 it('store merges into an existing canonical durable memory instead of appending a conflicting duplicate', async () => { 310 const base = memDb.add({ 311 agentId: 'agent-canonical-store', 312 category: 'projects/context', 313 title: 'Project Kodiak details', 314 content: 'Project Kodiak: codename amber-fox, freeze date April 18 2026', 315 }) 316 317 const result = await executeMemoryAction( 318 { 319 action: 'store', 320 title: 'Project Kodiak details', 321 value: 'Project Kodiak: codename amber-fox, freeze date April 21 2026', 322 category: 'projects/context', 323 }, 324 { agentId: 'agent-canonical-store', sessionId: 'agent-canonical-store', messages: [] }, 325 ) 326 327 assert.match(String(result), /updating the canonical entry/i) 328 const updated = memDb.get(base.id) 329 assert.ok(updated) 330 assert.match(String(updated?.content), /April 21 2026/) 331 332 const durableRows = memDb.list('agent-canonical-store', 20) 333 .filter((entry) => /Project Kodiak/.test(`${entry.title} ${entry.content}`)) 334 .filter((entry) => entry.category !== 'session_archive') 335 assert.equal(durableRows.filter((entry) => entry.id === base.id).length, 1) 336 }) 337 338 it('parses structured JSON payloads that arrive inside query or value fields', async () => { 339 const base = memDb.add({ 340 agentId: 'agent-structured-payload', 341 category: 'projects/decisions', 342 title: 'Project Kodiak codename and freeze date', 343 content: 'Project Kodiak uses the codename "amber-fox" and the freeze date is April 18, 2026.', 344 }) 345 346 const result = await executeMemoryAction( 347 { 348 action: 'update', 349 query: JSON.stringify({ 350 title: 'Project Kodiak codename and freeze date', 351 category: 'projects/decisions', 352 content: 'Project Kodiak uses the codename "amber-fox" and the freeze date is April 21, 2026.', 353 }), 354 }, 355 { agentId: 'agent-structured-payload', sessionId: 'agent-structured-payload', messages: [] }, 356 ) 357 358 assert.match(String(result), /Updated memory/) 359 const updated = memDb.get(base.id) 360 assert.ok(updated) 361 assert.match(String(updated?.content), /April 21, 2026/) 362 assert.doesNotMatch(String(updated?.content), /"title"/) 363 }) 364 }) 365 366 // ─── Pinned Memories ──────────────────────────────────────────────── 367 368 describe('Pinned memories', () => { 369 before(() => { 370 memDb.add({ agentId: 'agent-pin', category: 'note', title: 'Normal 1', content: 'not pinned one', pinned: false }) 371 memDb.add({ agentId: 'agent-pin', category: 'note', title: 'Normal 2', content: 'not pinned two', pinned: false }) 372 memDb.add({ agentId: 'agent-pin', category: 'note', title: 'Normal 3', content: 'not pinned three', pinned: false }) 373 memDb.add({ agentId: 'agent-pin', category: 'note', title: 'Pinned 1', content: 'pinned content one', pinned: true }) 374 memDb.add({ agentId: 'agent-pin', category: 'note', title: 'Pinned 2', content: 'pinned content two', pinned: true }) 375 }) 376 377 it('listPinned returns only pinned memories', () => { 378 const pinned = memDb.listPinned('agent-pin') 379 assert.ok(pinned.length >= 2, `expected at least 2 pinned, got ${pinned.length}`) 380 for (const entry of pinned) { 381 assert.ok(entry.pinned, `entry "${entry.title}" should be pinned`) 382 } 383 }) 384 }) 385 386 // ─── Category Normalization ───────────────────────────────────────── 387 388 describe('Category normalization (comprehensive)', () => { 389 const norm = (cat: string, title?: string, content?: string) => 390 memoryPolicy.normalizeMemoryCategory(cat, title ?? null, content ?? null) 391 392 it('maps flat categories to hierarchical', () => { 393 assert.equal(norm('preference'), 'identity/preferences') 394 assert.equal(norm('decision'), 'projects/decisions') 395 assert.equal(norm('error'), 'execution/errors') 396 assert.equal(norm('project'), 'projects/context') 397 assert.equal(norm('learning'), 'projects/learnings') 398 assert.equal(norm('breadcrumb'), 'operations/execution') 399 assert.equal(norm('fact'), 'knowledge/facts') 400 assert.equal(norm('working'), 'working/scratch') 401 }) 402 403 it('falls through to knowledge/facts when explicit is "note"', () => { 404 // normalizeMemoryCategory no longer content-sniffs — "note" maps to the default 405 assert.equal(norm('note', 'user prefers dark mode', ''), 'knowledge/facts') 406 assert.equal(norm('note', 'decided to ship Docker', ''), 'knowledge/facts') 407 assert.equal(norm('note', 'root cause was a null pointer', ''), 'knowledge/facts') 408 }) 409 410 it('passes through already-hierarchical categories', () => { 411 assert.equal(norm('identity/profile'), 'identity/profile') 412 assert.equal(norm('custom/bucket'), 'custom/bucket') 413 }) 414 }) 415 416 // ─── Memory Doctor Report ─────────────────────────────────────────── 417 418 describe('Memory doctor report', () => { 419 it('builds report with correct counts', () => { 420 const entries = [ 421 { id: '1', agentId: 'a', category: 'identity/preferences', title: '', content: '', pinned: true, linkedMemoryIds: ['2'], createdAt: 0, updatedAt: 0 }, 422 { id: '2', agentId: 'a', category: 'projects/decisions', title: '', content: '', pinned: false, linkedMemoryIds: ['1'], sharedWith: ['b'], createdAt: 0, updatedAt: 0 }, 423 { id: '3', agentId: 'a', category: 'knowledge/facts', title: '', content: '', pinned: true, createdAt: 0, updatedAt: 0 }, 424 { id: '4', agentId: null, category: 'operations/execution', title: '', content: '', pinned: false, sharedWith: ['a'], createdAt: 0, updatedAt: 0 }, 425 ] as unknown as import('@/types').MemoryEntry[] 426 427 const report = memoryPolicy.buildMemoryDoctorReport(entries, 'a') 428 assert.match(report, /Visible memories: 4/) 429 assert.match(report, /Pinned: 2/) 430 assert.match(report, /Linked: 2/) 431 assert.match(report, /Shared: 2/) 432 assert.match(report, /identity/) 433 assert.match(report, /projects/) 434 assert.match(report, /knowledge/) 435 assert.match(report, /operations/) 436 }) 437 }) 438 439 // ─── Auto-capture Policy ──────────────────────────────────────────── 440 441 describe('Auto-capture policy', () => { 442 it('shouldInjectMemoryContext: short ack → false', () => { 443 assert.equal(memoryPolicy.shouldInjectMemoryContext('ok'), false) 444 }) 445 446 it('shouldInjectMemoryContext: greeting → false', () => { 447 assert.equal(memoryPolicy.shouldInjectMemoryContext('hello'), false) 448 }) 449 450 it('shouldInjectMemoryContext: short memory meta → false', () => { 451 assert.equal(memoryPolicy.shouldInjectMemoryContext('remember this'), false) 452 }) 453 454 it('shouldInjectMemoryContext: substantive message → true', () => { 455 assert.equal( 456 memoryPolicy.shouldInjectMemoryContext('Compare the current deployment plan with what we decided yesterday'), 457 true, 458 ) 459 }) 460 461 it('shouldAutoCaptureMemoryTurn: short messages → false', () => { 462 assert.equal(memoryPolicy.shouldAutoCaptureMemoryTurn('hi', 'hello!'), false) 463 }) 464 465 it('shouldAutoCaptureMemoryTurn: ack + response → false', () => { 466 assert.equal( 467 memoryPolicy.shouldAutoCaptureMemoryTurn('thanks', 'You are welcome, happy to help with that!'), 468 false, 469 ) 470 }) 471 472 it('shouldAutoCaptureMemoryTurn: error response → false', () => { 473 assert.equal( 474 memoryPolicy.shouldAutoCaptureMemoryTurn( 475 'Please deploy the production environment now with all the settings', 476 "sorry, I can't do that because I don't have the credentials needed.", 477 ), 478 false, 479 ) 480 }) 481 482 it('shouldAutoCaptureMemoryTurn: substantive exchange → true', () => { 483 assert.equal( 484 memoryPolicy.shouldAutoCaptureMemoryTurn( 485 'We decided to use the shared staging environment and keep the worker count at 2 for now.', 486 'Decision captured: shared staging, worker count 2, and we will revisit after load testing next week.', 487 ), 488 true, 489 ) 490 }) 491 492 it('shouldAutoCaptureMemoryTurn: HEARTBEAT_OK response → false', () => { 493 assert.equal( 494 memoryPolicy.shouldAutoCaptureMemoryTurn( 495 'This is a real substantive question about the project and architecture', 496 'HEARTBEAT_OK all systems nominal', 497 ), 498 false, 499 ) 500 }) 501 }) 502 503 // ─── inferAutomaticMemoryCategory ─────────────────────────────────── 504 505 describe('inferAutomaticMemoryCategory', () => { 506 it('returns knowledge/facts since content-sniffing was removed', () => { 507 // inferAutomaticMemoryCategory delegates to normalizeMemoryCategory('note', ...), 508 // which no longer infers category from content — agents pick categories explicitly 509 assert.equal( 510 memoryPolicy.inferAutomaticMemoryCategory('user prefers dark mode', 'noted'), 511 'knowledge/facts', 512 ) 513 assert.equal( 514 memoryPolicy.inferAutomaticMemoryCategory('decided to ship Docker first', 'locked in'), 515 'knowledge/facts', 516 ) 517 assert.equal( 518 memoryPolicy.inferAutomaticMemoryCategory('root cause was a null pointer bug', 'fixed now'), 519 'knowledge/facts', 520 ) 521 }) 522 }) 523 524 // ─── Memory Deduplication ─────────────────────────────────────────── 525 526 describe('Memory deduplication via contentHash', () => { 527 it('storing same content twice reinforces instead of duplicating', () => { 528 const first = memDb.add({ agentId: 'agent-dedup', category: 'note', title: 'Dup test', content: 'exact duplicate content for dedup test' }) 529 const second = memDb.add({ agentId: 'agent-dedup', category: 'note', title: 'Dup test', content: 'exact duplicate content for dedup test' }) 530 assert.equal(first.id, second.id, 'second add should return same id') 531 assert.ok((second.reinforcementCount ?? 0) >= 1, 'reinforcement count should be bumped') 532 }) 533 }) 534 535 // ─── Unknown Action ───────────────────────────────────────────────── 536 537 describe('Unknown action', () => { 538 it('returns unknown action message', async () => { 539 const result = await executeMemoryAction({ action: 'invalid' }, null) 540 assert.match(String(result), /Unknown action/) 541 }) 542 }) 543 544 // ─── Edge Cases ───────────────────────────────────────────────────── 545 546 describe('Edge cases', () => { 547 it('store with empty value is rejected when no fallback fact exists', async () => { 548 const result = await executeMemoryAction( 549 { action: 'store', key: 'empty-val', value: '', category: 'note' }, 550 { agentId: 'agent-edge' }, 551 ) 552 assert.match(String(result), /requires a non-empty value/i) 553 }) 554 555 it('store with missing key defaults title to Untitled', async () => { 556 const result = await executeMemoryAction( 557 { action: 'store', value: 'some content without key', category: 'note' }, 558 { agentId: 'agent-edge' }, 559 ) 560 assert.match(String(result), /Stored memory/) 561 assert.match(String(result), /Untitled/) 562 }) 563 564 it('store with null context still works', async () => { 565 const result = await executeMemoryAction( 566 { action: 'store', key: 'null-ctx', value: 'null context test', category: 'note' }, 567 null, 568 ) 569 assert.match(String(result), /Stored memory/) 570 }) 571 572 it('store with imagePath that does not exist still stores', async () => { 573 const result = await executeMemoryAction( 574 { action: 'store', key: 'no-image', value: 'image missing', category: 'note', imagePath: '/tmp/nonexistent-image.png' }, 575 { agentId: 'agent-edge' }, 576 ) 577 assert.match(String(result), /Stored memory/) 578 }) 579 580 it('update non-existent memory → not found', async () => { 581 const result = await executeMemoryAction( 582 { action: 'update', id: 'nonexistent-id-xyz', value: 'updated' }, 583 { agentId: 'agent-edge' }, 584 ) 585 assert.match(String(result), /not found/i) 586 }) 587 588 it('get with non-existent id → not found', async () => { 589 const result = await executeMemoryAction( 590 { action: 'get', id: 'missing-id-abc' }, 591 { agentId: 'agent-edge' }, 592 ) 593 assert.match(String(result), /not found/i) 594 }) 595 596 it('link requires targetIds', async () => { 597 const entry = memDb.add({ agentId: 'agent-edge', category: 'note', title: 'Link test', content: 'link target test' }) 598 const result = await executeMemoryAction( 599 { action: 'link', id: entry.id }, 600 { agentId: 'agent-edge' }, 601 ) 602 assert.match(String(result), /requires targetIds/i) 603 }) 604 605 it('unlink requires targetIds', async () => { 606 const entry = memDb.add({ agentId: 'agent-edge', category: 'note', title: 'Unlink test', content: 'unlink target test' }) 607 const result = await executeMemoryAction( 608 { action: 'unlink', id: entry.id }, 609 { agentId: 'agent-edge' }, 610 ) 611 assert.match(String(result), /requires targetIds/i) 612 }) 613 614 it('delete non-existent memory → not found', async () => { 615 const result = await executeMemoryAction( 616 { action: 'delete', id: 'phantom-id-999' }, 617 { agentId: 'agent-edge' }, 618 ) 619 assert.match(String(result), /not found/i) 620 }) 621 }) 622 623 // ─── Doctor via executeMemoryAction ───────────────────────────────── 624 625 describe('Doctor action via executeMemoryAction', () => { 626 it('returns a doctor report', async () => { 627 const result = await executeMemoryAction( 628 { action: 'doctor' }, 629 { agentId: 'agent-crud' }, 630 ) 631 assert.match(String(result), /Memory Doctor/) 632 assert.match(String(result), /Visible memories/) 633 }) 634 }) 635 636 // ─── Direct memDb CRUD ────────────────────────────────────────────── 637 638 describe('Direct memDb CRUD', () => { 639 it('add, get, update, delete cycle', () => { 640 const entry = memDb.add({ agentId: 'direct-agent', category: 'note', title: 'Direct test', content: 'direct content' }) 641 assert.ok(entry.id) 642 assert.equal(entry.title, 'Direct test') 643 644 const fetched = memDb.get(entry.id) 645 assert.ok(fetched) 646 assert.equal(fetched.content, 'direct content') 647 648 const updated = memDb.update(entry.id, { title: 'Updated direct', content: 'updated direct content' }) 649 assert.ok(updated) 650 assert.equal(updated.title, 'Updated direct') 651 652 memDb.delete(entry.id) 653 const gone = memDb.get(entry.id) 654 assert.equal(gone, null) 655 }) 656 657 it('list returns entries and respects updatedAt ordering', () => { 658 const a = memDb.add({ agentId: 'list-agent', category: 'note', title: 'First', content: 'first entry for list test' }) 659 const b = memDb.add({ agentId: 'list-agent', category: 'note', title: 'Second', content: 'second entry for list test' }) 660 const entries = memDb.list('list-agent', 10) 661 assert.ok(entries.length >= 2, 'should list at least 2 entries') 662 assert.ok(entries.some((e) => e.id === a.id), 'should include entry a') 663 assert.ok(entries.some((e) => e.id === b.id), 'should include entry b') 664 // Verify entries are sorted by updatedAt descending (ties allowed) 665 for (let i = 1; i < entries.length; i++) { 666 assert.ok(entries[i - 1].updatedAt >= entries[i].updatedAt, 'list should be ordered by updatedAt desc') 667 } 668 }) 669 670 it('search via FTS finds matching entries', () => { 671 memDb.add({ agentId: 'search-agent', category: 'note', title: 'Kubernetes deployment', content: 'helm chart configuration for kubernetes cluster' }) 672 const results = memDb.search('kubernetes helm chart', 'search-agent') 673 assert.ok(results.length >= 1, 'FTS should find the kubernetes entry') 674 assert.ok(results.some((r) => r.title === 'Kubernetes deployment')) 675 }) 676 677 it('update returns null for non-existent id', () => { 678 const result = memDb.update('missing-xyz', { title: 'no' }) 679 assert.equal(result, null) 680 }) 681 }) 682 683 // ─── Link and Unlink via executeMemoryAction ──────────────────────── 684 685 describe('Link and unlink via executeMemoryAction', () => { 686 let id1 = '' 687 let id2 = '' 688 689 before(() => { 690 const entry1 = memDb.add({ agentId: 'agent-act-link', category: 'note', title: 'Link A', content: 'link action A' }) 691 const entry2 = memDb.add({ agentId: 'agent-act-link', category: 'note', title: 'Link B', content: 'link action B' }) 692 id1 = entry1.id 693 id2 = entry2.id 694 }) 695 696 it('links memories via action', async () => { 697 const result = await executeMemoryAction( 698 { action: 'link', id: id1, targetIds: [id2] }, 699 { agentId: 'agent-act-link' }, 700 ) 701 assert.match(String(result), /Linked/) 702 const entry = memDb.get(id1)! 703 assert.ok(entry.linkedMemoryIds?.includes(id2)) 704 }) 705 706 it('unlinks memories via action', async () => { 707 const result = await executeMemoryAction( 708 { action: 'unlink', id: id1, targetIds: [id2] }, 709 { agentId: 'agent-act-link' }, 710 ) 711 assert.match(String(result), /Unlinked/) 712 const entry = memDb.get(id1)! 713 const links = entry.linkedMemoryIds || [] 714 assert.ok(!links.includes(id2)) 715 }) 716 })