This commit is contained in:
304
packages/hive-mind-core/tests/multi-mind.test.ts
Normal file
304
packages/hive-mind-core/tests/multi-mind.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { MindDB } from '../src/mind/db.js';
|
||||
import { FrameStore } from '../src/mind/frames.js';
|
||||
import { IdentityLayer } from '../src/mind/identity.js';
|
||||
import { AwarenessLayer } from '../src/mind/awareness.js';
|
||||
import { MultiMind } from '../src/multi-mind.js';
|
||||
|
||||
describe('MultiMind', () => {
|
||||
let mm: MultiMind;
|
||||
|
||||
afterEach(() => {
|
||||
mm?.close();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('opens with personal mind only', () => {
|
||||
mm = new MultiMind(':memory:');
|
||||
expect(mm.personal).toBeInstanceOf(MindDB);
|
||||
expect(mm.workspace).toBeNull();
|
||||
});
|
||||
|
||||
it('opens with personal and workspace minds', () => {
|
||||
mm = new MultiMind(':memory:', ':memory:');
|
||||
expect(mm.personal).toBeInstanceOf(MindDB);
|
||||
expect(mm.workspace).toBeInstanceOf(MindDB);
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity', () => {
|
||||
beforeEach(() => {
|
||||
mm = new MultiMind(':memory:', ':memory:');
|
||||
});
|
||||
|
||||
it('returns identity from personal mind', () => {
|
||||
const identity = new IdentityLayer(mm.personal);
|
||||
identity.create({
|
||||
name: 'TestAgent',
|
||||
role: 'assistant',
|
||||
department: 'engineering',
|
||||
personality: 'helpful',
|
||||
capabilities: 'memory, search',
|
||||
system_prompt: 'You are a test agent.',
|
||||
});
|
||||
|
||||
const result = mm.getIdentity();
|
||||
expect(result.name).toBe('TestAgent');
|
||||
expect(result.role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('hasIdentity returns false when no identity set', () => {
|
||||
expect(mm.hasIdentity()).toBe(false);
|
||||
});
|
||||
|
||||
it('hasIdentity returns true when identity exists', () => {
|
||||
const identity = new IdentityLayer(mm.personal);
|
||||
identity.create({
|
||||
name: 'TestAgent',
|
||||
role: 'assistant',
|
||||
department: '',
|
||||
personality: '',
|
||||
capabilities: '',
|
||||
system_prompt: '',
|
||||
});
|
||||
expect(mm.hasIdentity()).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when getting identity that does not exist', () => {
|
||||
expect(() => mm.getIdentity()).toThrow('No identity configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('awareness', () => {
|
||||
beforeEach(() => {
|
||||
mm = new MultiMind(':memory:', ':memory:');
|
||||
});
|
||||
|
||||
it('returns combined awareness from both minds', () => {
|
||||
const personalAwareness = new AwarenessLayer(mm.personal);
|
||||
personalAwareness.add('task', 'Personal task', 5);
|
||||
|
||||
const workspaceAwareness = new AwarenessLayer(mm.workspace!);
|
||||
workspaceAwareness.add('task', 'Workspace task', 3);
|
||||
|
||||
const items = mm.getAwareness();
|
||||
expect(items).toHaveLength(2);
|
||||
// Sorted by priority desc
|
||||
expect(items[0].content).toBe('Personal task');
|
||||
expect(items[1].content).toBe('Workspace task');
|
||||
});
|
||||
|
||||
it('returns only personal awareness when no workspace', () => {
|
||||
mm.close();
|
||||
mm = new MultiMind(':memory:');
|
||||
|
||||
const personalAwareness = new AwarenessLayer(mm.personal);
|
||||
personalAwareness.add('flag', 'Personal flag', 1);
|
||||
|
||||
const items = mm.getAwareness();
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].content).toBe('Personal flag');
|
||||
});
|
||||
|
||||
it('sorts combined awareness by priority descending', () => {
|
||||
const personalAwareness = new AwarenessLayer(mm.personal);
|
||||
personalAwareness.add('task', 'Low prio personal', 1);
|
||||
|
||||
const workspaceAwareness = new AwarenessLayer(mm.workspace!);
|
||||
workspaceAwareness.add('task', 'High prio workspace', 10);
|
||||
|
||||
const items = mm.getAwareness();
|
||||
expect(items[0].content).toBe('High prio workspace');
|
||||
expect(items[1].content).toBe('Low prio personal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
beforeEach(() => {
|
||||
mm = new MultiMind(':memory:', ':memory:');
|
||||
});
|
||||
|
||||
function addMemory(db: MindDB, gopId: string, content: string) {
|
||||
// Create a session first (foreign key)
|
||||
db.getDatabase().prepare(
|
||||
"INSERT OR IGNORE INTO sessions (gop_id, status) VALUES (?, 'active')"
|
||||
).run(gopId);
|
||||
const frames = new FrameStore(db);
|
||||
return frames.createIFrame(gopId, content);
|
||||
}
|
||||
|
||||
it('searches across both minds', () => {
|
||||
addMemory(mm.personal, 'gop-1', 'TypeScript is a programming language');
|
||||
addMemory(mm.workspace!, 'gop-2', 'TypeScript project setup guide');
|
||||
|
||||
const results = mm.searchAll('TypeScript');
|
||||
expect(results).toHaveLength(2);
|
||||
const sources = results.map(r => r.source);
|
||||
expect(sources).toContain('personal');
|
||||
expect(sources).toContain('workspace');
|
||||
});
|
||||
|
||||
it('searches personal only', () => {
|
||||
addMemory(mm.personal, 'gop-1', 'Python data analysis');
|
||||
addMemory(mm.workspace!, 'gop-2', 'Python web framework');
|
||||
|
||||
const results = mm.search('Python', 'personal');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].source).toBe('personal');
|
||||
expect(results[0].content).toBe('Python data analysis');
|
||||
});
|
||||
|
||||
it('searches workspace only', () => {
|
||||
addMemory(mm.personal, 'gop-1', 'React components guide');
|
||||
addMemory(mm.workspace!, 'gop-2', 'React hooks tutorial');
|
||||
|
||||
const results = mm.search('React', 'workspace');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].source).toBe('workspace');
|
||||
expect(results[0].content).toBe('React hooks tutorial');
|
||||
});
|
||||
|
||||
it('returns empty array for no matches', () => {
|
||||
addMemory(mm.personal, 'gop-1', 'Something unrelated');
|
||||
const results = mm.searchAll('xyznonexistent');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('respects the limit parameter', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
addMemory(mm.personal, `gop-p${i}`, `JavaScript lesson number ${i}`);
|
||||
}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
addMemory(mm.workspace!, `gop-w${i}`, `JavaScript tutorial number ${i}`);
|
||||
}
|
||||
|
||||
const results = mm.searchAll('JavaScript', 3);
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('handles search with no workspace gracefully', () => {
|
||||
mm.close();
|
||||
mm = new MultiMind(':memory:');
|
||||
|
||||
addMemory(mm.personal, 'gop-1', 'Rust memory safety');
|
||||
|
||||
const results = mm.search('Rust', 'all');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].source).toBe('personal');
|
||||
});
|
||||
|
||||
it('workspace scope returns empty when no workspace', () => {
|
||||
mm.close();
|
||||
mm = new MultiMind(':memory:');
|
||||
|
||||
addMemory(mm.personal, 'gop-1', 'Some content');
|
||||
|
||||
const results = mm.search('content', 'workspace');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sanitizes FTS5 queries with special characters', () => {
|
||||
addMemory(mm.personal, 'gop-1', 'Error handling in production');
|
||||
|
||||
// Should not throw even with special chars
|
||||
const results = mm.searchAll('error "handling"');
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('finds Cyrillic and diacritic content (S1 Unicode sanitizer)', () => {
|
||||
addMemory(mm.personal, 'gop-1', 'Београд је главни град Србије');
|
||||
addMemory(mm.workspace!, 'gop-2', 'Čitanje časopisa o žurkama');
|
||||
|
||||
expect(mm.searchAll('Београд')).toHaveLength(1);
|
||||
expect(mm.search('časopisa', 'workspace')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('switchWorkspace', () => {
|
||||
it('switches from no workspace to a new workspace', () => {
|
||||
mm = new MultiMind(':memory:');
|
||||
expect(mm.workspace).toBeNull();
|
||||
|
||||
mm.switchWorkspace(':memory:');
|
||||
expect(mm.workspace).toBeInstanceOf(MindDB);
|
||||
});
|
||||
|
||||
it('switches between workspaces', () => {
|
||||
mm = new MultiMind(':memory:', ':memory:');
|
||||
const oldWorkspace = mm.workspace;
|
||||
|
||||
// Add something to old workspace
|
||||
const oldAwareness = new AwarenessLayer(mm.workspace!);
|
||||
oldAwareness.add('task', 'Old workspace task', 1);
|
||||
|
||||
mm.switchWorkspace(':memory:');
|
||||
expect(mm.workspace).not.toBe(oldWorkspace);
|
||||
|
||||
// New workspace should be empty
|
||||
const items = mm.getAwareness();
|
||||
// Only personal items (none added), no workspace items
|
||||
expect(items.every(i => i.content !== 'Old workspace task')).toBe(true);
|
||||
});
|
||||
|
||||
it('new workspace is searchable after switch', () => {
|
||||
mm = new MultiMind(':memory:', ':memory:');
|
||||
|
||||
mm.switchWorkspace(':memory:');
|
||||
|
||||
// Add memory to new workspace
|
||||
mm.workspace!.getDatabase().prepare(
|
||||
"INSERT OR IGNORE INTO sessions (gop_id, status) VALUES (?, 'active')"
|
||||
).run('gop-new');
|
||||
const frames = new FrameStore(mm.workspace!);
|
||||
frames.createIFrame('gop-new', 'New workspace content about databases');
|
||||
|
||||
const results = mm.search('databases', 'workspace');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].source).toBe('workspace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('layer accessors', () => {
|
||||
beforeEach(() => {
|
||||
mm = new MultiMind(':memory:', ':memory:');
|
||||
});
|
||||
|
||||
it('returns personal FrameStore', () => {
|
||||
expect(mm.getFrameStore('personal')).toBeInstanceOf(FrameStore);
|
||||
});
|
||||
|
||||
it('returns workspace FrameStore', () => {
|
||||
expect(mm.getFrameStore('workspace')).toBeInstanceOf(FrameStore);
|
||||
});
|
||||
|
||||
it('returns null workspace FrameStore when no workspace', () => {
|
||||
mm.close();
|
||||
mm = new MultiMind(':memory:');
|
||||
expect(mm.getFrameStore('workspace')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns personal AwarenessLayer', () => {
|
||||
expect(mm.getAwarenessLayer('personal')).toBeInstanceOf(AwarenessLayer);
|
||||
});
|
||||
|
||||
it('returns workspace AwarenessLayer', () => {
|
||||
expect(mm.getAwarenessLayer('workspace')).toBeInstanceOf(AwarenessLayer);
|
||||
});
|
||||
|
||||
it('returns IdentityLayer from personal mind', () => {
|
||||
expect(mm.getIdentityLayer()).toBeInstanceOf(IdentityLayer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('closes both minds without error', () => {
|
||||
mm = new MultiMind(':memory:', ':memory:');
|
||||
expect(() => mm.close()).not.toThrow();
|
||||
});
|
||||
|
||||
it('closes personal-only mind without error', () => {
|
||||
mm = new MultiMind(':memory:');
|
||||
expect(() => mm.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user