import { beforeEach, describe, expect, test } from 'bun:test'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { createAgentMemoryActions } from './actions.js'; import { createAgentMemoryRuntime } from './runtime.js'; import { createProjectIdFromPath } from '../projects/project-id.js'; const DIRECTORY = '/tmp/some-project'; class TestError extends Error { constructor(message, status) { super(message); this.status = status; } } let actions; let runtime; beforeEach(async () => { const rootDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-memory-actions-')); runtime = createAgentMemoryRuntime({ fsPromises, path, userConfigRoot: path.join(rootDir, 'config'), projectsDirPath: path.join(rootDir, 'config', 'projects'), }); actions = createAgentMemoryActions({ agentMemoryRuntime: runtime, createError: (message, status) => new TestError(message, status), resolveProjectId: async (directory) => createProjectIdFromPath(directory), }); }); describe('scope', () => { test('project scope files against the session directory, not a model-supplied id', async () => { await actions.execute('memory.save', { scope: 'project', title: 'Uses bun', body: 'Tests run with bun test.', projectId: 'path_somewhere_else', }, DIRECTORY); const stored = await runtime.read({ scope: 'project', projectId: createProjectIdFromPath(DIRECTORY), }); expect(stored.entries.map((entry) => entry.title)).toEqual(['Uses bun']); }); test('project scope without a session directory fails instead of writing global', async () => { await expect(actions.execute('memory.save', { scope: 'project', title: 'T', body: 'b' }, null)) .rejects.toThrow('needs a session directory'); expect((await runtime.read({ scope: 'global' })).entries).toHaveLength(0); }); test('an unknown scope is rejected', async () => { await expect(actions.execute('memory.save', { scope: 'team', title: 'T', body: 'b' }, DIRECTORY)) .rejects.toThrow('scope must be global or project'); }); test('an unknown action is rejected', async () => { await expect(actions.execute('memory.forget', {}, DIRECTORY)).rejects.toThrow('Unsupported memory action'); }); }); describe('save', () => { test('requires title and body', async () => { await expect(actions.execute('memory.save', { scope: 'global', body: 'b' }, DIRECTORY)) .rejects.toThrow('title is required'); await expect(actions.execute('memory.save', { scope: 'global', title: 't' }, DIRECTORY)) .rejects.toThrow('body is required'); }); test('rejects an unknown type', async () => { await expect(actions.execute('memory.save', { scope: 'global', title: 't', body: 'b', type: 'nonsense', }, DIRECTORY)).rejects.toThrow('type must be'); }); test('reports a correction as replaced so the agent does not claim a second memory', async () => { await actions.execute('memory.save', { scope: 'global', title: 'Prefers Ukrainian replies', body: 'The user wants answers written in Ukrainian.', }, DIRECTORY); const result = await actions.execute('memory.save', { scope: 'global', title: 'Answers should be in Ukrainian', body: 'The user wants replies written in Ukrainian.', }, DIRECTORY); expect(result.replaced).toBe(true); expect((await runtime.read({ scope: 'global' })).entries).toHaveLength(1); }); test('announces the write so an open panel can show it', async () => { const seen = []; const announcing = createAgentMemoryActions({ agentMemoryRuntime: runtime, createError: (message, status) => new TestError(message, status), resolveProjectId: async (directory) => createProjectIdFromPath(directory), onMemoryChanged: (event) => seen.push(event), }); await announcing.execute('memory.save', { scope: 'project', title: 'T', body: 'b' }, DIRECTORY); expect(seen).toEqual([{ scope: 'project', projectId: createProjectIdFromPath(DIRECTORY) }]); }); test('a broken listener does not fail the write', async () => { const announcing = createAgentMemoryActions({ agentMemoryRuntime: runtime, createError: (message, status) => new TestError(message, status), resolveProjectId: async (directory) => createProjectIdFromPath(directory), onMemoryChanged: () => { throw new Error('listener exploded'); }, }); const result = await announcing.execute('memory.save', { scope: 'global', title: 'T', body: 'b' }, DIRECTORY); // The memory is already on disk; a broken notification must not report it // back as a failure. expect(result.memory.title).toBe('T'); }); }); describe('worktree sessions reach the project store', () => { test('every memory action resolves the directory through the project resolver', async () => { const WORKTREE = '/tmp/worktree-checkout'; const worktreeAware = createAgentMemoryActions({ agentMemoryRuntime: runtime, createError: (message, status) => new TestError(message, status), // A worktree session must land in the project's store, not one keyed by // the worktree path that the panel never reads. resolveProjectId: async () => createProjectIdFromPath(DIRECTORY), }); const saved = await worktreeAware.execute('memory.save', { scope: 'project', title: 'Learned in a worktree', body: 'Body.', }, WORKTREE); expect((await runtime.read({ scope: 'project', projectId: createProjectIdFromPath(DIRECTORY) })).entries) .toHaveLength(1); // Reading and listing must agree with the write, or the agent would store // something it can never find again. const read = await worktreeAware.execute('memory.read', { scope: 'project', memoryId: saved.memory.memoryId, }, WORKTREE); expect(read.memory.body).toBe('Body.'); const listed = await worktreeAware.execute('memory.list', {}, WORKTREE); expect(listed.memories.map((memory) => memory.title)).toEqual(['Learned in a worktree']); await worktreeAware.execute('memory.delete', { scope: 'project', memoryId: saved.memory.memoryId, }, WORKTREE); expect((await runtime.read({ scope: 'project', projectId: createProjectIdFromPath(DIRECTORY) })).entries) .toHaveLength(0); }); }); describe('read', () => { test('reads by the title the session index shows', async () => { await actions.execute('memory.save', { scope: 'global', title: 'Uses bun', body: 'Full text here.' }, DIRECTORY); const result = await actions.execute('memory.read', { scope: 'global', title: 'uses BUN' }, DIRECTORY); expect(result.memory.body).toBe('Full text here.'); }); test('reads by id', async () => { const saved = await actions.execute('memory.save', { scope: 'global', title: 'T', body: 'Full text.' }, DIRECTORY); const result = await actions.execute('memory.read', { scope: 'global', memoryId: saved.memory.memoryId, }, DIRECTORY); expect(result.memory.body).toBe('Full text.'); }); test('requires something to look up', async () => { await expect(actions.execute('memory.read', { scope: 'global' }, DIRECTORY)) .rejects.toThrow('requires memoryId or title'); }); test('a miss is reported, not answered with an empty memory', async () => { await expect(actions.execute('memory.read', { scope: 'global', title: 'absent' }, DIRECTORY)) .rejects.toThrow('No memory matches'); }); test('finds a memory without being told which store holds it', async () => { // Scope decides everything for a write, but for a read it is only which // drawer to open — demanding it turned a legible request into an error. await actions.execute('memory.save', { scope: 'global', title: 'About user', body: 'Global text.' }, DIRECTORY); const result = await actions.execute('memory.read', { title: 'About user' }, DIRECTORY); expect(result.memory.body).toBe('Global text.'); expect(result.memory.scope).toBe('global'); }); test('prefers the project store when both hold the same title', async () => { await actions.execute('memory.save', { scope: 'global', title: 'Shared', body: 'Global text.' }, DIRECTORY); await actions.execute('memory.save', { scope: 'project', title: 'Shared', body: 'Project text.' }, DIRECTORY); const result = await actions.execute('memory.read', { title: 'Shared' }, DIRECTORY); expect(result.memory.scope).toBe('project'); }); test('an unscoped miss is still reported', async () => { await expect(actions.execute('memory.read', { title: 'absent' }, DIRECTORY)) .rejects.toThrow('No memory matches'); }); test('a store that failed to load is not reported as an absent memory', async () => { const failing = createAgentMemoryActions({ agentMemoryRuntime: { readAll: async () => ({ global: [], project: [], globalFailed: true, projectFailed: false }), }, createError: (message, status) => new TestError(message, status), resolveProjectId: async (directory) => createProjectIdFromPath(directory), }); // Answering "no such memory" would send the agent off to store it again. await expect(failing.execute('memory.read', { title: 'anything' }, DIRECTORY)) .rejects.toThrow('could not be read'); }); test('does not reach across scopes', async () => { await actions.execute('memory.save', { scope: 'project', title: 'Uses bun', body: 'x' }, DIRECTORY); await expect(actions.execute('memory.read', { scope: 'global', title: 'Uses bun' }, DIRECTORY)) .rejects.toThrow('No memory matches'); }); }); describe('list', () => { test('lists both scopes by default and labels which is which', async () => { await actions.execute('memory.save', { scope: 'global', title: 'About user', body: 'x' }, DIRECTORY); await actions.execute('memory.save', { scope: 'project', title: 'About project', body: 'y' }, DIRECTORY); const result = await actions.execute('memory.list', {}, DIRECTORY); expect(result.memories.map((memory) => [memory.title, memory.scope])).toEqual([ ['About user', 'global'], ['About project', 'project'], ]); }); test('listing never carries bodies', async () => { await actions.execute('memory.save', { scope: 'global', title: 'T', body: 'Long body text.' }, DIRECTORY); const result = await actions.execute('memory.list', { scope: 'global' }, DIRECTORY); expect(result.memories[0].body).toBeUndefined(); }); test('a broken scope is reported rather than shown as empty', async () => { const failing = createAgentMemoryActions({ agentMemoryRuntime: { readAll: async () => ({ global: [], project: [], globalFailed: true, projectFailed: false }), }, createError: (message, status) => new TestError(message, status), resolveProjectId: async (directory) => createProjectIdFromPath(directory), }); const result = await failing.execute('memory.list', {}, DIRECTORY); expect(result.globalUnavailable).toBe(true); }); }); describe('delete', () => { test('removes the entry', async () => { const saved = await actions.execute('memory.save', { scope: 'global', title: 'T', body: 'b' }, DIRECTORY); await actions.execute('memory.delete', { scope: 'global', memoryId: saved.memory.memoryId }, DIRECTORY); expect((await runtime.read({ scope: 'global' })).entries).toHaveLength(0); }); test('requires an id', async () => { await expect(actions.execute('memory.delete', { scope: 'global' }, DIRECTORY)) .rejects.toThrow('memoryId is required'); }); test('reports a miss instead of claiming success', async () => { await expect(actions.execute('memory.delete', { scope: 'global', memoryId: 'absent' }, DIRECTORY)) .rejects.toThrow('No memory has that id'); }); }); describe('when the user switches memory off', () => { const disabled = () => createAgentMemoryActions({ agentMemoryRuntime: runtime, createError: (message, status) => new TestError(message, status), resolveProjectId: async (directory) => createProjectIdFromPath(directory), isAgentMemoryEnabled: async () => false, }); test('refuses to write, so nothing accumulates unseen', async () => { // The tool lives in the OpenCode child until it restarts, so the agent can // still call this after the switch goes off. Those writes would land on // disk while the panel showing them is hidden. await expect(disabled().execute('memory.save', { scope: 'global', title: 'T', body: 'b' }, DIRECTORY)) .rejects.toThrow('switched off'); expect((await runtime.read({ scope: 'global' })).entries).toHaveLength(0); }); test('refuses to read as well', async () => { await expect(disabled().execute('memory.list', {}, DIRECTORY)).rejects.toThrow('switched off'); await expect(disabled().execute('memory.read', { title: 'x' }, DIRECTORY)).rejects.toThrow('switched off'); }); test('an unreadable setting closes the surface rather than opening it', async () => { const unknown = createAgentMemoryActions({ agentMemoryRuntime: runtime, createError: (message, status) => new TestError(message, status), resolveProjectId: async (directory) => createProjectIdFromPath(directory), isAgentMemoryEnabled: async () => { throw new Error('settings unreadable'); }, }); await expect(unknown.execute('memory.save', { scope: 'global', title: 'T', body: 'b' }, DIRECTORY)) .rejects.toThrow('switched off'); }); test('works normally while it is on', async () => { const on = createAgentMemoryActions({ agentMemoryRuntime: runtime, createError: (message, status) => new TestError(message, status), resolveProjectId: async (directory) => createProjectIdFromPath(directory), isAgentMemoryEnabled: async () => true, }); const result = await on.execute('memory.save', { scope: 'global', title: 'T', body: 'b' }, DIRECTORY); expect(result.saved).toBe(true); }); });