345 lines
13 KiB
JavaScript
345 lines
13 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|||
|
|
import fsPromises from 'node:fs/promises';
|
||
|
|
import os from 'node:os';
|
||
|
|
import path from 'node:path';
|
||
|
|
|
||
|
|
import { createAgentMemoryRuntime } from './runtime.js';
|
||
|
|
|
||
|
|
const PROJECT_ID = 'path_dGVzdA';
|
||
|
|
const GLOBAL = { scope: 'global' };
|
||
|
|
const PROJECT = { scope: 'project', projectId: PROJECT_ID };
|
||
|
|
|
||
|
|
let rootDir;
|
||
|
|
let runtime;
|
||
|
|
let idCounter;
|
||
|
|
|
||
|
|
const globalPath = () => path.join(rootDir, 'config', 'memory.json');
|
||
|
|
const projectPath = () => path.join(rootDir, 'config', 'projects', PROJECT_ID, 'memory.json');
|
||
|
|
|
||
|
|
const writeJson = async (filePath, value) => {
|
||
|
|
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
||
|
|
await fsPromises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||
|
|
};
|
||
|
|
|
||
|
|
beforeEach(async () => {
|
||
|
|
rootDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-agent-memory-'));
|
||
|
|
idCounter = 0;
|
||
|
|
runtime = createAgentMemoryRuntime({
|
||
|
|
fsPromises,
|
||
|
|
path,
|
||
|
|
userConfigRoot: path.join(rootDir, 'config'),
|
||
|
|
projectsDirPath: path.join(rootDir, 'config', 'projects'),
|
||
|
|
createId: () => `mem-${++idCounter}`,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(async () => {
|
||
|
|
await fsPromises.rm(rootDir, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('scope resolution', () => {
|
||
|
|
test('the two scopes are separate files', async () => {
|
||
|
|
await runtime.create(GLOBAL, { title: 'Speaks Ukrainian', body: 'Replies should be in Ukrainian.' });
|
||
|
|
await runtime.create(PROJECT, { title: 'Uses bun', body: 'Tests run with bun test.' });
|
||
|
|
|
||
|
|
expect((await runtime.read(GLOBAL)).entries.map((e) => e.title)).toEqual(['Speaks Ukrainian']);
|
||
|
|
expect((await runtime.read(PROJECT)).entries.map((e) => e.title)).toEqual(['Uses bun']);
|
||
|
|
await fsPromises.access(globalPath());
|
||
|
|
await fsPromises.access(projectPath());
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rejects an unknown scope', async () => {
|
||
|
|
await expect(runtime.read({ scope: 'nope' })).rejects.toThrow('scope is required');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rejects a traversal projectId', async () => {
|
||
|
|
await expect(runtime.read({ scope: 'project', projectId: '../escape' }))
|
||
|
|
.rejects.toThrow('unsupported characters');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('project scope requires an id', async () => {
|
||
|
|
await expect(runtime.read({ scope: 'project' })).rejects.toThrow('projectId is required');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('read', () => {
|
||
|
|
test('missing file is authoritative empty', async () => {
|
||
|
|
expect(await runtime.read(GLOBAL)).toEqual({ version: 1, entries: [] });
|
||
|
|
});
|
||
|
|
|
||
|
|
test('malformed storage fails instead of reading as empty', async () => {
|
||
|
|
await fsPromises.mkdir(path.dirname(globalPath()), { recursive: true });
|
||
|
|
await fsPromises.writeFile(globalPath(), '{ not json', 'utf8');
|
||
|
|
|
||
|
|
await expect(runtime.read(GLOBAL)).rejects.toThrow('malformed');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('drops malformed entries without failing the read', async () => {
|
||
|
|
await writeJson(globalPath(), {
|
||
|
|
version: 1,
|
||
|
|
entries: [
|
||
|
|
{ id: 'a', title: 'Kept', body: 'body', createdAt: 1, updatedAt: 1 },
|
||
|
|
{ id: '', title: 'No id', body: 'body' },
|
||
|
|
{ id: 'c', title: '', body: 'no title' },
|
||
|
|
{ id: 'd', title: 'No body', body: ' ' },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect((await runtime.read(GLOBAL)).entries.map((e) => e.id)).toEqual(['a']);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('most recently updated is listed first', async () => {
|
||
|
|
await writeJson(globalPath(), {
|
||
|
|
version: 1,
|
||
|
|
entries: [
|
||
|
|
{ id: 'old', title: 'Old', body: 'x', createdAt: 1, updatedAt: 1 },
|
||
|
|
{ id: 'new', title: 'New', body: 'x', createdAt: 1, updatedAt: 9 },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect((await runtime.read(GLOBAL)).entries.map((e) => e.id)).toEqual(['new', 'old']);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('an unknown type falls back to fact', async () => {
|
||
|
|
await writeJson(globalPath(), {
|
||
|
|
version: 1,
|
||
|
|
entries: [{ id: 'a', title: 'T', body: 'b', type: 'nonsense', createdAt: 1, updatedAt: 1 }],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect((await runtime.read(GLOBAL)).entries[0].type).toBe('fact');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('create', () => {
|
||
|
|
test('stores title, body, type and provenance', async () => {
|
||
|
|
const { entry } = await runtime.create(PROJECT, {
|
||
|
|
title: 'Bun test',
|
||
|
|
body: 'Run tests per file.',
|
||
|
|
type: 'reference',
|
||
|
|
sessionId: 'ses_1',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(entry.type).toBe('reference');
|
||
|
|
expect(entry.sessionId).toBe('ses_1');
|
||
|
|
expect(entry.createdAt).toBe(entry.updatedAt);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rejects an empty title or body', async () => {
|
||
|
|
await expect(runtime.create(GLOBAL, { title: ' ', body: 'x' })).rejects.toThrow('title is required');
|
||
|
|
await expect(runtime.create(GLOBAL, { title: 'x', body: ' ' })).rejects.toThrow('body is required');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('clamps oversized fields', async () => {
|
||
|
|
const { entry } = await runtime.create(GLOBAL, { title: 'x'.repeat(300), body: 'y'.repeat(5000) });
|
||
|
|
|
||
|
|
expect(entry.title).toHaveLength(60);
|
||
|
|
expect(entry.body).toHaveLength(2000);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('the same title updates in place instead of duplicating', async () => {
|
||
|
|
const first = await runtime.create(PROJECT, { title: 'Uses bun', body: 'old body' });
|
||
|
|
const second = await runtime.create(PROJECT, { title: 'uses BUN', body: 'new body' });
|
||
|
|
|
||
|
|
expect(second.replaced).toBe(true);
|
||
|
|
expect(second.entry.id).toBe(first.entry.id);
|
||
|
|
expect(second.entry.createdAt).toBe(first.entry.createdAt);
|
||
|
|
expect((await runtime.read(PROJECT)).entries).toHaveLength(1);
|
||
|
|
expect((await runtime.read(PROJECT)).entries[0].body).toBe('new body');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('the same title in a different scope is a separate entry', async () => {
|
||
|
|
await runtime.create(GLOBAL, { title: 'Shared title', body: 'global' });
|
||
|
|
await runtime.create(PROJECT, { title: 'Shared title', body: 'project' });
|
||
|
|
|
||
|
|
expect((await runtime.read(GLOBAL)).entries[0].body).toBe('global');
|
||
|
|
expect((await runtime.read(PROJECT)).entries[0].body).toBe('project');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('global memory is capped tighter than project memory', async () => {
|
||
|
|
const entries = Array.from({ length: 60 }, (_unused, index) => ({
|
||
|
|
id: `g${index}`, title: `Global ${index}`, body: 'x', createdAt: index, updatedAt: index,
|
||
|
|
}));
|
||
|
|
await writeJson(globalPath(), { version: 1, entries });
|
||
|
|
|
||
|
|
await expect(runtime.create(GLOBAL, { title: 'One more', body: 'x' }))
|
||
|
|
.rejects.toThrow('global memory is full');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('project memory refuses to grow past its own limit', async () => {
|
||
|
|
const entries = Array.from({ length: 200 }, (_unused, index) => ({
|
||
|
|
id: `p${index}`, title: `Project ${index}`, body: 'x', createdAt: index, updatedAt: index,
|
||
|
|
}));
|
||
|
|
await writeJson(projectPath(), { version: 1, entries });
|
||
|
|
|
||
|
|
await expect(runtime.create(PROJECT, { title: 'One more', body: 'x' }))
|
||
|
|
.rejects.toThrow('project memory is full');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('concurrent creates all survive', async () => {
|
||
|
|
await Promise.all([
|
||
|
|
runtime.create(PROJECT, { title: 'A', body: 'a' }),
|
||
|
|
runtime.create(PROJECT, { title: 'B', body: 'b' }),
|
||
|
|
runtime.create(PROJECT, { title: 'C', body: 'c' }),
|
||
|
|
]);
|
||
|
|
|
||
|
|
expect((await runtime.read(PROJECT)).entries.map((e) => e.title).sort()).toEqual(['A', 'B', 'C']);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('remove', () => {
|
||
|
|
test('deletes only the requested entry', async () => {
|
||
|
|
const keep = await runtime.create(PROJECT, { title: 'Keep', body: 'x' });
|
||
|
|
const drop = await runtime.create(PROJECT, { title: 'Drop', body: 'x' });
|
||
|
|
|
||
|
|
const result = await runtime.remove(PROJECT, drop.entry.id);
|
||
|
|
expect(result.deleted).toBe(true);
|
||
|
|
expect(result.entries.map((e) => e.id)).toEqual([keep.entry.id]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('reports no deletion for an unknown entry', async () => {
|
||
|
|
expect((await runtime.remove(PROJECT, 'missing')).deleted).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('readAll', () => {
|
||
|
|
test('returns both scopes', async () => {
|
||
|
|
await runtime.create(GLOBAL, { title: 'G', body: 'x' });
|
||
|
|
await runtime.create(PROJECT, { title: 'P', body: 'x' });
|
||
|
|
|
||
|
|
const all = await runtime.readAll(PROJECT_ID);
|
||
|
|
expect(all.global.map((e) => e.title)).toEqual(['G']);
|
||
|
|
expect(all.project.map((e) => e.title)).toEqual(['P']);
|
||
|
|
expect(all.globalFailed).toBe(false);
|
||
|
|
expect(all.projectFailed).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a broken project scope does not hide the global scope', async () => {
|
||
|
|
await runtime.create(GLOBAL, { title: 'G', body: 'x' });
|
||
|
|
await fsPromises.mkdir(path.dirname(projectPath()), { recursive: true });
|
||
|
|
await fsPromises.writeFile(projectPath(), '{ broken', 'utf8');
|
||
|
|
|
||
|
|
const all = await runtime.readAll(PROJECT_ID);
|
||
|
|
expect(all.global.map((e) => e.title)).toEqual(['G']);
|
||
|
|
expect(all.project).toEqual([]);
|
||
|
|
expect(all.projectFailed).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('works with no project at all', async () => {
|
||
|
|
await runtime.create(GLOBAL, { title: 'G', body: 'x' });
|
||
|
|
|
||
|
|
const all = await runtime.readAll(null);
|
||
|
|
expect(all.global).toHaveLength(1);
|
||
|
|
expect(all.project).toEqual([]);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('restated duplicates', () => {
|
||
|
|
test('a reworded restatement replaces the entry instead of adding a second', async () => {
|
||
|
|
await runtime.create(PROJECT, {
|
||
|
|
title: 'Run UI tests per file',
|
||
|
|
body: 'UI tests must run one file at a time because module mocks leak between files.',
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await runtime.create(PROJECT, {
|
||
|
|
title: 'UI tests run one file at a time',
|
||
|
|
body: 'Because module mocks leak between files, UI tests must run per file.',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.replaced).toBe(true);
|
||
|
|
expect(result.entries).toHaveLength(1);
|
||
|
|
expect(result.entry.title).toBe('UI tests run one file at a time');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('keeps entries that merely share vocabulary', async () => {
|
||
|
|
await runtime.create(PROJECT, {
|
||
|
|
title: 'Package manager',
|
||
|
|
body: 'This project installs dependencies with bun install.',
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await runtime.create(PROJECT, {
|
||
|
|
title: 'Test runner',
|
||
|
|
body: 'This project executes its unit suites through vitest.',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.replaced).toBe(false);
|
||
|
|
expect(result.entries).toHaveLength(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('short entries fall back to exact-title matching', async () => {
|
||
|
|
await runtime.create(PROJECT, { title: 'Runtime', body: 'Use bun.' });
|
||
|
|
const result = await runtime.create(PROJECT, { title: 'Bundler', body: 'Use vite.' });
|
||
|
|
|
||
|
|
expect(result.replaced).toBe(false);
|
||
|
|
expect(result.entries).toHaveLength(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a replacement bumps updatedAt so the panel can show it as changed', async () => {
|
||
|
|
const first = await runtime.create(PROJECT, {
|
||
|
|
title: 'Run UI tests per file',
|
||
|
|
body: 'UI tests must run one file at a time because module mocks leak between files.',
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
||
|
|
|
||
|
|
const second = await runtime.create(PROJECT, {
|
||
|
|
title: 'UI tests run one file at a time',
|
||
|
|
body: 'Because module mocks leak between files, UI tests must run per file.',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(second.entry.createdAt).toBe(first.entry.createdAt);
|
||
|
|
expect(second.entry.updatedAt).toBeGreaterThan(first.entry.updatedAt);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a full store can still correct an entry it already holds', async () => {
|
||
|
|
for (let index = 0; index < 60; index += 1) {
|
||
|
|
await runtime.create(GLOBAL, { title: `Entry ${index}`, body: `Body number ${index}.` });
|
||
|
|
}
|
||
|
|
await expect(runtime.create(GLOBAL, { title: 'One more', body: 'Overflows the store.' }))
|
||
|
|
.rejects.toThrow('memory is full');
|
||
|
|
|
||
|
|
const result = await runtime.create(GLOBAL, { title: 'Entry 7', body: 'Corrected body.' });
|
||
|
|
|
||
|
|
expect(result.replaced).toBe(true);
|
||
|
|
expect(result.entries).toHaveLength(60);
|
||
|
|
expect(result.entry.body).toBe('Corrected body.');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('user corrections', () => {
|
||
|
|
test('rewrites the wording without changing identity', async () => {
|
||
|
|
const { entry } = await runtime.create(PROJECT, { title: 'Vague', body: 'Original.' });
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
||
|
|
|
||
|
|
const result = await runtime.update(PROJECT, entry.id, { title: 'Clear', body: 'Reworded.' });
|
||
|
|
|
||
|
|
expect(result.entry.id).toBe(entry.id);
|
||
|
|
expect(result.entry.createdAt).toBe(entry.createdAt);
|
||
|
|
expect(result.entry.updatedAt).toBeGreaterThan(entry.updatedAt);
|
||
|
|
expect(result.entry.title).toBe('Clear');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('patches only the named fields', async () => {
|
||
|
|
const { entry } = await runtime.create(PROJECT, { title: 'Kept', body: 'Original.' });
|
||
|
|
|
||
|
|
const result = await runtime.update(PROJECT, entry.id, { body: 'Reworded.' });
|
||
|
|
|
||
|
|
expect(result.entry.title).toBe('Kept');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('refuses to empty a field', async () => {
|
||
|
|
const { entry } = await runtime.create(PROJECT, { title: 'T', body: 'b' });
|
||
|
|
|
||
|
|
await expect(runtime.update(PROJECT, entry.id, { title: ' ' })).rejects.toThrow('title is required');
|
||
|
|
await expect(runtime.update(PROJECT, entry.id, { body: ' ' })).rejects.toThrow('body is required');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rejects an empty patch', async () => {
|
||
|
|
const { entry } = await runtime.create(PROJECT, { title: 'T', body: 'b' });
|
||
|
|
|
||
|
|
await expect(runtime.update(PROJECT, entry.id, {})).rejects.toThrow('title, body or type is required');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('an unknown id is reported, not invented', async () => {
|
||
|
|
expect(await runtime.update(PROJECT, 'absent', { body: 'x' })).toBeNull();
|
||
|
|
});
|
||
|
|
});
|