463 lines
20 KiB
JavaScript
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
|