305 lines
9.8 KiB
TypeScript
305 lines
9.8 KiB
TypeScript
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();
|
||
});
|
||
});
|
||
});
|