9f81c01603
Store per-session config in SQLite and route /model and /reset through command fast-paths so channel sessions keep independent model selection across reconnects and restarts.
259 lines
9.9 KiB
TypeScript
259 lines
9.9 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { MemoryStore } from './store.js';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
describe('MemoryStore', () => {
|
|
let dir: string;
|
|
let store: MemoryStore;
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), 'flynn-memory-test-'));
|
|
store = new MemoryStore({ dir, maxContextTokens: 4096 });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('read', () => {
|
|
it('returns empty string for non-existent namespace', () => {
|
|
const result = store.read('nonexistent');
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
it('returns file contents for existing namespace', () => {
|
|
// Pre-populate a memory file directly on disk
|
|
const filePath = join(dir, 'notes.md');
|
|
writeFileSync(filePath, 'Remember to check logs daily');
|
|
|
|
const result = store.read('notes');
|
|
expect(result).toBe('Remember to check logs daily');
|
|
});
|
|
|
|
it('handles nested namespaces (sessions/abc123)', () => {
|
|
// Pre-populate a nested memory file
|
|
const nestedDir = join(dir, 'sessions');
|
|
mkdirSync(nestedDir, { recursive: true });
|
|
writeFileSync(join(nestedDir, 'abc123.md'), 'Session context data');
|
|
|
|
const result = store.read('sessions/abc123');
|
|
expect(result).toBe('Session context data');
|
|
});
|
|
});
|
|
|
|
describe('write', () => {
|
|
it('creates file and directories on first write', () => {
|
|
store.write('projects/alpha', 'Initial content', 'replace');
|
|
|
|
const filePath = join(dir, 'projects', 'alpha.md');
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
expect(content).toContain('Initial content');
|
|
});
|
|
|
|
it('appends content with newline separator in append mode', () => {
|
|
store.write('journal', 'First entry', 'replace');
|
|
store.write('journal', 'Second entry', 'append');
|
|
|
|
const result = store.read('journal');
|
|
expect(result).toContain('First entry');
|
|
expect(result).toContain('Second entry');
|
|
|
|
// Verify entries are separated by newline
|
|
const lines = result.split('\n').filter((l: string) => l.trim().length > 0);
|
|
expect(lines.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('replaces content in replace mode', () => {
|
|
store.write('config', 'Old content', 'replace');
|
|
store.write('config', 'New content', 'replace');
|
|
|
|
const result = store.read('config');
|
|
expect(result).toContain('New content');
|
|
expect(result).not.toContain('Old content');
|
|
});
|
|
|
|
it('handles nested namespace paths', () => {
|
|
store.write('deep/nested/path', 'Deep content', 'replace');
|
|
|
|
const result = store.read('deep/nested/path');
|
|
expect(result).toContain('Deep content');
|
|
});
|
|
});
|
|
|
|
describe('category APIs', () => {
|
|
it('reads and writes category namespaces', () => {
|
|
store.writeCategory('user', 'facts', 'User lives in Berlin', 'replace');
|
|
expect(store.readCategory('user', 'facts')).toBe('User lives in Berlin');
|
|
});
|
|
|
|
it('supports append and replace modes in category writes', () => {
|
|
store.writeCategory('user', 'preferences', 'Prefers short answers', 'replace');
|
|
store.writeCategory('user', 'preferences', 'Likes numbered lists', 'append');
|
|
expect(store.readCategory('user', 'preferences')).toContain('Prefers short answers');
|
|
expect(store.readCategory('user', 'preferences')).toContain('Likes numbered lists');
|
|
|
|
store.writeCategory('user', 'preferences', 'Only this remains', 'replace');
|
|
const content = store.readCategory('user', 'preferences');
|
|
expect(content).toContain('Only this remains');
|
|
expect(content).not.toContain('Prefers short answers');
|
|
});
|
|
|
|
it('lists only categories that exist under a base namespace', () => {
|
|
store.writeCategory('user', 'facts', 'Fact', 'replace');
|
|
store.writeCategory('user', 'projects', 'Project', 'replace');
|
|
store.writeCategory('global', 'decisions', 'Decision', 'replace');
|
|
|
|
expect(store.listCategories('user')).toEqual(['facts', 'projects']);
|
|
expect(store.listCategories('global')).toEqual(['decisions']);
|
|
expect(store.listCategories('sessions/abc')).toEqual([]);
|
|
});
|
|
|
|
it('reads all existing categories under a base namespace', () => {
|
|
store.writeCategory('user', 'facts', 'Fact content', 'replace');
|
|
store.writeCategory('user', 'decisions', 'Decision content', 'replace');
|
|
|
|
expect(store.readAllCategories('user')).toEqual({
|
|
facts: 'Fact content',
|
|
decisions: 'Decision content',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('search', () => {
|
|
beforeEach(() => {
|
|
store.write('notes', 'The quick brown fox jumps over the lazy dog\nAnother line of text\nFox sightings are common here', 'replace');
|
|
store.write('journal', 'Today I saw a fox in the garden\nIt was a beautiful day\nThe weather was sunny', 'replace');
|
|
store.write('config', 'No relevant animals mentioned\nJust some settings here', 'replace');
|
|
});
|
|
|
|
it('finds matching lines case-insensitively', () => {
|
|
const results = store.search('fox');
|
|
|
|
// Should match lines containing "fox" regardless of case
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const contents = results.map((r) => r.content.toLowerCase());
|
|
for (const content of contents) {
|
|
expect(content).toContain('fox');
|
|
}
|
|
});
|
|
|
|
it('returns context lines around matches', () => {
|
|
const results = store.search('fox');
|
|
|
|
// Each result should have a context field with surrounding text
|
|
for (const result of results) {
|
|
expect(result.context).toBeDefined();
|
|
expect(result.context.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('searches across multiple namespaces', () => {
|
|
const results = store.search('fox');
|
|
|
|
// Should find matches in both 'notes' and 'journal' namespaces
|
|
const namespaces = new Set(results.map((r) => r.namespace));
|
|
expect(namespaces.size).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('returns empty array when no matches', () => {
|
|
const results = store.search('xyznonexistent');
|
|
expect(results).toEqual([]);
|
|
});
|
|
|
|
it('supports filtering by category', () => {
|
|
store.writeCategory('user', 'facts', 'fox factual statement', 'replace');
|
|
store.writeCategory('user', 'preferences', 'prefers fox metaphors', 'replace');
|
|
|
|
const factOnly = store.search('fox', { categories: ['facts'] });
|
|
expect(factOnly.length).toBeGreaterThan(0);
|
|
expect(factOnly.every(result => result.namespace.endsWith('/facts'))).toBe(true);
|
|
});
|
|
|
|
it('supports filtering by base namespace prefix', () => {
|
|
store.writeCategory('user', 'facts', 'fox in user facts', 'replace');
|
|
store.writeCategory('global', 'facts', 'fox in global facts', 'replace');
|
|
|
|
const userOnly = store.search('fox', { baseNamespacePrefix: 'user/' });
|
|
expect(userOnly.length).toBeGreaterThan(0);
|
|
expect(userOnly.every(result => result.namespace.startsWith('user/'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('listNamespaces', () => {
|
|
it('returns empty array when no files exist', () => {
|
|
const namespaces = store.listNamespaces();
|
|
expect(namespaces).toEqual([]);
|
|
});
|
|
|
|
it('lists all namespaces from existing files', () => {
|
|
store.write('notes', 'Some notes', 'replace');
|
|
store.write('journal', 'A journal entry', 'replace');
|
|
store.write('config', 'Configuration data', 'replace');
|
|
|
|
const namespaces = store.listNamespaces();
|
|
|
|
expect(namespaces).toContain('notes');
|
|
expect(namespaces).toContain('journal');
|
|
expect(namespaces).toContain('config');
|
|
expect(namespaces).toHaveLength(3);
|
|
});
|
|
|
|
it('includes nested namespaces', () => {
|
|
store.write('sessions/abc123', 'Session data', 'replace');
|
|
store.write('sessions/def456', 'Another session', 'replace');
|
|
store.write('global', 'Global notes', 'replace');
|
|
|
|
const namespaces = store.listNamespaces();
|
|
|
|
expect(namespaces).toContain('sessions/abc123');
|
|
expect(namespaces).toContain('sessions/def456');
|
|
expect(namespaces).toContain('global');
|
|
});
|
|
});
|
|
|
|
describe('getContextForPrompt', () => {
|
|
it('returns empty string when no memory files exist', () => {
|
|
const context = store.getContextForPrompt();
|
|
expect(context).toBe('');
|
|
});
|
|
|
|
it('includes user and global memory under headings', () => {
|
|
store.write('user', 'User prefers concise answers', 'replace');
|
|
store.write('global', 'System-wide knowledge base', 'replace');
|
|
store.writeCategory('user', 'facts', 'User timezone is UTC', 'replace');
|
|
store.writeCategory('global', 'decisions', 'Adopt pnpm workspace', 'replace');
|
|
|
|
const context = store.getContextForPrompt();
|
|
|
|
// Should contain both memory sections with some form of heading
|
|
expect(context).toContain('User Memory');
|
|
expect(context).toContain('User prefers concise answers');
|
|
expect(context).toContain('Global Memory');
|
|
expect(context).toContain('System-wide knowledge base');
|
|
expect(context).toContain('User Facts');
|
|
expect(context).toContain('User timezone is UTC');
|
|
expect(context).toContain('Global Decisions');
|
|
expect(context).toContain('Adopt pnpm workspace');
|
|
});
|
|
|
|
it('truncates content to stay within maxContextTokens', () => {
|
|
// Create a store with a very small token limit (50 tokens ≈ 200 chars)
|
|
const smallStore = new MemoryStore({ dir, maxContextTokens: 50 });
|
|
|
|
// Write a large amount of content to user and global (which getContextForPrompt reads)
|
|
const longContent = 'A'.repeat(5000);
|
|
smallStore.write('user', longContent, 'replace');
|
|
smallStore.write('global', longContent, 'replace');
|
|
|
|
const context = smallStore.getContextForPrompt();
|
|
|
|
// 50 tokens * 4 chars/token = 200 chars max
|
|
expect(context.length).toBeLessThanOrEqual(200);
|
|
expect(context.length).toBeLessThan(longContent.length);
|
|
});
|
|
});
|
|
});
|