The panel stored notes, todos and plans inside one shared JSON file that six unrelated domains also wrote to, synchronised itself through window CustomEvents, and could only read plans. It is now Project knowledge: server-owned storage with explicit routes, a store with rollback, a section sidebar, plans that open and edit in place, and search across all of it. Notes and plans the user pins travel with every message sent in that project. Pinning is project state, not an attachment to one message, so it holds until unpinned and the work status panel names what is riding along and can detach it. Agent memory is added alongside, in two scopes: what is true about the user, and what is true about this codebase. The split is not cosmetic — a wrong project fact costs one project and is noticed, while a wrong global fact quietly shapes every session everywhere and the user has no code to check it against. It stays separate from notes so an agent mistake cannot land in what the user wrote. Sessions receive an index of titles only; bodies are read on demand, because an index carrying full text grows until it crowds out the conversation. Deciding what a session must be told, and whether it has been told, now lives on the server. The client owned it before, which meant sessions started without a UI — scheduled tasks, sessions the agent dispatches — received nothing at all, and a tab's record of what it had sent outlived the conversation: after compaction the agent no longer held the block while the tab went on believing it did. What was delivered is recorded in the session's own metadata, and compaction restores it through the runtime that already restores pinned messages, in the same turn. Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there is no tool, no routes, no session index, no settings row and no panel tab. Absent rather than switched off, so nothing invites turning on a feature that has not been announced. Pinned notes and plans are unaffected and ship as normal.
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();
|
|
});
|
|
});
|