Files
flynn/src/memory/store.test.ts
T
William Valentin 9f81c01603 feat(session): persist model tier overrides per session
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.
2026-02-13 01:04:26 -08:00

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);
});
});
});