tasq/node_modules/@claude-flow/memory/dist/agent-memory-scope.test.js

463 lines
20 KiB
JavaScript

/**
* Tests for Agent-Scoped Memory
*
* TDD London School (mock-first) tests for the 3-scope agent memory system.
* Uses vi.mock for ESM-compatible fs mocking.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as path from 'node:path';
// ESM-compatible mock: vi.mock is hoisted above imports automatically
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
existsSync: vi.fn(actual.existsSync),
readdirSync: vi.fn(actual.readdirSync),
statSync: vi.fn(actual.statSync),
};
});
import { existsSync, readdirSync, statSync } from 'node:fs';
import { resolveAgentMemoryDir, createAgentBridge, transferKnowledge, listAgentScopes, } from './agent-memory-scope.js';
import { createDefaultEntry } from './types.js';
import { AutoMemoryBridge } from './auto-memory-bridge.js';
// Cast mocked fs functions for test control
const mockExistsSync = existsSync;
const mockReaddirSync = readdirSync;
const mockStatSync = statSync;
// ===== Mock Backend =====
function createMockBackend(entries = []) {
return {
initialize: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
store: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue(null),
getByKey: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(null),
delete: vi.fn().mockResolvedValue(true),
query: vi.fn().mockResolvedValue(entries),
search: vi.fn().mockResolvedValue([]),
bulkInsert: vi.fn().mockResolvedValue(undefined),
bulkDelete: vi.fn().mockResolvedValue(0),
count: vi.fn().mockResolvedValue(0),
listNamespaces: vi.fn().mockResolvedValue([]),
clearNamespace: vi.fn().mockResolvedValue(0),
getStats: vi.fn().mockResolvedValue({
totalEntries: 0,
entriesByNamespace: {},
entriesByType: {},
memoryUsage: 0,
avgQueryTime: 0,
avgSearchTime: 0,
}),
healthCheck: vi.fn().mockResolvedValue({
status: 'healthy',
components: {
storage: { status: 'healthy', latency: 0 },
index: { status: 'healthy', latency: 0 },
cache: { status: 'healthy', latency: 0 },
},
timestamp: Date.now(),
issues: [],
recommendations: [],
}),
};
}
// ===== Test Fixtures =====
function createTestEntry(overrides = {}) {
const base = createDefaultEntry({
key: 'test-key',
content: 'Test content for knowledge transfer',
namespace: 'learnings',
tags: ['insight', 'architecture'],
metadata: {
confidence: 0.95,
category: 'architecture',
summary: 'Use event sourcing for state changes',
},
});
// When overrides.metadata is provided, use it directly (don't merge with base metadata)
const metadata = overrides.metadata !== undefined ? overrides.metadata : base.metadata;
return { ...base, ...overrides, metadata };
}
// ===== resolveAgentMemoryDir =====
describe('resolveAgentMemoryDir', () => {
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
beforeEach(() => {
mockExistsSync.mockReset();
// Default: no .git found anywhere
mockExistsSync.mockReturnValue(false);
});
afterEach(() => {
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
});
it('should resolve project scope to gitRoot/.claude/agent-memory/name/', () => {
mockExistsSync.mockImplementation((p) => {
return String(p) === path.join('/workspaces/my-project', '.git');
});
const result = resolveAgentMemoryDir('coder', 'project', '/workspaces/my-project/src');
expect(result).toBe(path.join('/workspaces/my-project', '.claude', 'agent-memory', 'coder'));
});
it('should resolve local scope to gitRoot/.claude/agent-memory-local/name/', () => {
mockExistsSync.mockImplementation((p) => {
return String(p) === path.join('/workspaces/my-project', '.git');
});
const result = resolveAgentMemoryDir('researcher', 'local', '/workspaces/my-project/src');
expect(result).toBe(path.join('/workspaces/my-project', '.claude', 'agent-memory-local', 'researcher'));
});
it('should resolve user scope to ~/.claude/agent-memory/name/', () => {
process.env.HOME = '/home/testuser';
const result = resolveAgentMemoryDir('planner', 'user');
expect(result).toBe(path.join('/home/testuser', '.claude', 'agent-memory', 'planner'));
});
it('should sanitize agent name by replacing special characters', () => {
process.env.HOME = '/home/testuser';
const result = resolveAgentMemoryDir('my agent!@#name', 'user');
expect(result).toBe(path.join('/home/testuser', '.claude', 'agent-memory', 'my_agent___name'));
});
it('should handle path traversal attempts in agent name', () => {
process.env.HOME = '/home/testuser';
const result = resolveAgentMemoryDir('../../../etc/passwd', 'user');
expect(result).toContain('______etc_passwd');
expect(result).not.toContain('..');
});
it('should handle dots and slashes in agent name', () => {
process.env.HOME = '/home/testuser';
const result = resolveAgentMemoryDir('agent/with.dots', 'user');
expect(result).toBe(path.join('/home/testuser', '.claude', 'agent-memory', 'agent_with_dots'));
});
it('should fall back to workingDir when no git root is found', () => {
mockExistsSync.mockReturnValue(false);
const result = resolveAgentMemoryDir('coder', 'project', '/some/dir');
expect(result).toBe(path.join('/some/dir', '.claude', 'agent-memory', 'coder'));
});
it('should fall back to USERPROFILE when HOME is not set', () => {
delete process.env.HOME;
process.env.USERPROFILE = '/Users/testuser';
const result = resolveAgentMemoryDir('coder', 'user');
expect(result).toBe(path.join('/Users/testuser', '.claude', 'agent-memory', 'coder'));
});
it('should use cwd as fallback for project scope when workingDir is omitted', () => {
mockExistsSync.mockReturnValue(false);
const cwd = process.cwd();
const result = resolveAgentMemoryDir('coder', 'project');
expect(result).toBe(path.join(cwd, '.claude', 'agent-memory', 'coder'));
});
it('should preserve hyphens and underscores in agent name', () => {
process.env.HOME = '/home/testuser';
const result = resolveAgentMemoryDir('my-agent_01', 'user');
expect(result).toBe(path.join('/home/testuser', '.claude', 'agent-memory', 'my-agent_01'));
});
});
// ===== createAgentBridge =====
describe('createAgentBridge', () => {
beforeEach(() => {
mockExistsSync.mockReset();
mockExistsSync.mockReturnValue(false);
});
it('should create a bridge with correct memoryDir for project scope', () => {
mockExistsSync.mockImplementation((p) => {
return String(p) === path.join('/workspaces/project', '.git');
});
const backend = createMockBackend();
const bridge = createAgentBridge(backend, {
agentName: 'coder',
scope: 'project',
workingDir: '/workspaces/project',
});
expect(bridge).toBeInstanceOf(AutoMemoryBridge);
expect(bridge.getMemoryDir()).toBe(path.join('/workspaces/project', '.claude', 'agent-memory', 'coder'));
bridge.destroy();
});
it('should create a bridge with correct memoryDir for user scope', () => {
const originalHome = process.env.HOME;
process.env.HOME = '/home/testuser';
const backend = createMockBackend();
const bridge = createAgentBridge(backend, {
agentName: 'reviewer',
scope: 'user',
});
expect(bridge).toBeInstanceOf(AutoMemoryBridge);
expect(bridge.getMemoryDir()).toBe(path.join('/home/testuser', '.claude', 'agent-memory', 'reviewer'));
bridge.destroy();
process.env.HOME = originalHome;
});
it('should create a bridge with correct memoryDir for local scope', () => {
mockExistsSync.mockImplementation((p) => {
return String(p) === path.join('/workspaces/project', '.git');
});
const backend = createMockBackend();
const bridge = createAgentBridge(backend, {
agentName: 'tester',
scope: 'local',
workingDir: '/workspaces/project',
});
expect(bridge).toBeInstanceOf(AutoMemoryBridge);
expect(bridge.getMemoryDir()).toBe(path.join('/workspaces/project', '.claude', 'agent-memory-local', 'tester'));
bridge.destroy();
});
it('should pass through other config options to AutoMemoryBridge', () => {
const backend = createMockBackend();
const bridge = createAgentBridge(backend, {
agentName: 'coder',
scope: 'project',
workingDir: '/tmp/test',
syncMode: 'on-session-end',
maxIndexLines: 100,
minConfidence: 0.9,
});
expect(bridge).toBeInstanceOf(AutoMemoryBridge);
bridge.destroy();
});
});
// ===== transferKnowledge =====
describe('transferKnowledge', () => {
let targetBridge;
let targetBackend;
beforeEach(() => {
mockExistsSync.mockReset();
mockExistsSync.mockReturnValue(false);
targetBackend = createMockBackend();
targetBridge = new AutoMemoryBridge(targetBackend, {
memoryDir: '/tmp/test-agent-memory',
syncMode: 'on-session-end',
});
});
afterEach(() => {
targetBridge.destroy();
});
it('should transfer high-confidence entries', async () => {
const entry = createTestEntry({
metadata: { confidence: 0.95, category: 'architecture', summary: 'Use event sourcing' },
});
const sourceBackend = createMockBackend([entry]);
const result = await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'learnings',
minConfidence: 0.8,
});
expect(result.transferred).toBe(1);
expect(result.skipped).toBe(0);
expect(targetBackend.store).toHaveBeenCalled();
});
it('should skip entries below minConfidence', async () => {
const lowConfEntry = createTestEntry({
metadata: { confidence: 0.3, category: 'debugging', summary: 'Low conf item' },
});
const sourceBackend = createMockBackend([lowConfEntry]);
const result = await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'learnings',
minConfidence: 0.8,
});
expect(result.transferred).toBe(0);
expect(result.skipped).toBe(1);
});
it('should filter by categories when specified', async () => {
const archEntry = createTestEntry({
metadata: { confidence: 0.95, category: 'architecture', summary: 'Arch pattern' },
});
const secEntry = createTestEntry({
metadata: { confidence: 0.95, category: 'security', summary: 'Security pattern' },
});
const sourceBackend = createMockBackend([archEntry, secEntry]);
const result = await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'learnings',
categories: ['architecture'],
});
expect(result.transferred).toBe(1);
expect(result.skipped).toBe(1);
});
it('should respect maxEntries limit', async () => {
const entries = Array.from({ length: 10 }, (_, i) => createTestEntry({
key: `entry-${i}`,
metadata: { confidence: 0.95, category: 'architecture', summary: `Pattern ${i}` },
}));
const sourceBackend = createMockBackend(entries);
const result = await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'learnings',
maxEntries: 3,
});
expect(result.transferred).toBe(3);
});
it('should handle empty source', async () => {
const sourceBackend = createMockBackend([]);
const result = await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'learnings',
});
expect(result.transferred).toBe(0);
expect(result.skipped).toBe(0);
});
it('should set transfer source metadata on insights', async () => {
const entry = createTestEntry({
metadata: { confidence: 0.95, category: 'architecture', summary: 'Test pattern' },
});
const sourceBackend = createMockBackend([entry]);
const recordSpy = vi.spyOn(targetBridge, 'recordInsight');
await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'my-namespace',
});
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
source: 'transfer:my-namespace',
}));
});
it('should use default category when entry has no category metadata', async () => {
const entry = createTestEntry({
metadata: { confidence: 0.95, summary: 'No category' },
});
const sourceBackend = createMockBackend([entry]);
const recordSpy = vi.spyOn(targetBridge, 'recordInsight');
await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'learnings',
});
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
category: 'project-patterns',
}));
});
it('should use first line of content as summary when metadata.summary is missing', async () => {
const entry = createTestEntry({
content: 'First line summary\nSecond line detail',
metadata: { confidence: 0.9 },
});
const sourceBackend = createMockBackend([entry]);
const recordSpy = vi.spyOn(targetBridge, 'recordInsight');
await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'learnings',
});
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
summary: 'First line summary',
}));
});
it('should include entries without category when no category filter is set', async () => {
const entry = createTestEntry({
metadata: { confidence: 0.95 },
});
const sourceBackend = createMockBackend([entry]);
const result = await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'learnings',
});
expect(result.transferred).toBe(1);
});
it('should default minConfidence to 0.8 when not specified', async () => {
const borderline = createTestEntry({
key: 'border',
metadata: { confidence: 0.79, summary: 'Borderline' },
});
const passing = createTestEntry({
key: 'passing',
metadata: { confidence: 0.81, summary: 'Passing' },
});
const sourceBackend = createMockBackend([borderline, passing]);
const result = await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'learnings',
});
expect(result.transferred).toBe(1);
expect(result.skipped).toBe(1);
});
it('should default maxEntries to 20 when not specified', async () => {
const entries = Array.from({ length: 30 }, (_, i) => createTestEntry({
key: `entry-${i}`,
metadata: { confidence: 0.95, summary: `Pattern ${i}` },
}));
const sourceBackend = createMockBackend(entries);
const result = await transferKnowledge(sourceBackend, targetBridge, {
sourceNamespace: 'learnings',
});
expect(result.transferred).toBe(20);
});
});
// ===== listAgentScopes =====
describe('listAgentScopes', () => {
const originalHome = process.env.HOME;
beforeEach(() => {
mockExistsSync.mockReset();
mockReaddirSync.mockReset();
mockStatSync.mockReset();
// Default: nothing exists
mockExistsSync.mockReturnValue(false);
});
afterEach(() => {
process.env.HOME = originalHome;
});
it('should return empty agents when dirs do not exist', () => {
process.env.HOME = '/home/testuser';
mockExistsSync.mockReturnValue(false);
const scopes = listAgentScopes('/workspaces/project');
expect(scopes).toHaveLength(3);
expect(scopes[0]).toEqual({ scope: 'project', agents: [] });
expect(scopes[1]).toEqual({ scope: 'local', agents: [] });
expect(scopes[2]).toEqual({ scope: 'user', agents: [] });
});
it('should list agents from existing directories', () => {
process.env.HOME = '/home/testuser';
// Compute the expected directories (no git root, falls back to workingDir)
const projectDir = path.join('/workspaces/project', '.claude', 'agent-memory');
const localDir = path.join('/workspaces/project', '.claude', 'agent-memory-local');
const userDir = path.join('/home/testuser', '.claude', 'agent-memory');
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (s === projectDir)
return true;
if (s === localDir)
return true;
if (s === userDir)
return true;
// No .git found
return false;
});
mockReaddirSync.mockImplementation((p) => {
const s = String(p);
if (s === projectDir)
return ['coder', 'tester'];
if (s === localDir)
return ['researcher'];
if (s === userDir)
return ['planner'];
return [];
});
mockStatSync.mockReturnValue({ isDirectory: () => true });
const scopes = listAgentScopes('/workspaces/project');
expect(scopes[0]).toEqual({ scope: 'project', agents: ['coder', 'tester'] });
expect(scopes[1]).toEqual({ scope: 'local', agents: ['researcher'] });
expect(scopes[2]).toEqual({ scope: 'user', agents: ['planner'] });
});
it('should return all three scopes in order: project, local, user', () => {
mockExistsSync.mockReturnValue(false);
const scopes = listAgentScopes('/tmp/test');
expect(scopes.map((s) => s.scope)).toEqual(['project', 'local', 'user']);
});
it('should handle readdir errors gracefully', () => {
process.env.HOME = '/home/testuser';
const projectDir = path.join('/workspaces/project', '.claude', 'agent-memory');
mockExistsSync.mockImplementation((p) => {
return String(p) === projectDir;
});
mockReaddirSync.mockImplementation(() => {
throw new Error('Permission denied');
});
const scopes = listAgentScopes('/workspaces/project');
expect(scopes[0]).toEqual({ scope: 'project', agents: [] });
});
it('should skip non-directory entries', () => {
process.env.HOME = '/home/testuser';
const projectDir = path.join('/workspaces/project', '.claude', 'agent-memory');
mockExistsSync.mockImplementation((p) => {
return String(p) === projectDir;
});
mockReaddirSync.mockImplementation((p) => {
if (String(p) === projectDir)
return ['coder', 'readme.md'];
return [];
});
let callCount = 0;
mockStatSync.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return { isDirectory: () => true };
}
return { isDirectory: () => false };
});
const scopes = listAgentScopes('/workspaces/project');
expect(scopes[0].agents).toEqual(['coder']);
});
});
//# sourceMappingURL=agent-memory-scope.test.js.map