271 lines
8.8 KiB
JavaScript
271 lines
8.8 KiB
JavaScript
import express from 'express';
|
|||
|
|
import request from 'supertest';
|
||
|
|
import { describe, expect, it } from 'vitest';
|
||
|
|
|
||
|
|
import { registerAgentMemoryRoutes } from './routes.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* End-to-end route tests over real HTTP.
|
||
|
|
*
|
||
|
|
* Mounted on a bare express app, exactly as production runs: `core-routes`
|
||
|
|
* parses only an allowlist of path prefixes so the OpenCode proxy keeps an
|
||
|
|
* unread stream. The PATCH route is the one that carries a body, so it is the
|
||
|
|
* one that has to attach its own `express.json()` — and these tests are what
|
||
|
|
* would fail if it stopped.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const entry = (overrides = {}) => ({
|
||
|
|
id: 'mem-1',
|
||
|
|
title: 'Uses bun',
|
||
|
|
body: 'Tests run with bun test.',
|
||
|
|
type: 'fact',
|
||
|
|
createdAt: 1,
|
||
|
|
updatedAt: 1,
|
||
|
|
...overrides,
|
||
|
|
});
|
||
|
|
|
||
|
|
const createApp = (overrides = {}) => {
|
||
|
|
const received = {};
|
||
|
|
const runtime = {
|
||
|
|
read: async (target) => {
|
||
|
|
received.readTarget = target;
|
||
|
|
return { version: 1, entries: [entry()] };
|
||
|
|
},
|
||
|
|
readAll: async (projectId) => {
|
||
|
|
received.readAllProjectId = projectId;
|
||
|
|
return { global: [entry()], project: [], globalFailed: false, projectFailed: false };
|
||
|
|
},
|
||
|
|
update: async (target, memoryId, patch) => {
|
||
|
|
received.updateTarget = target;
|
||
|
|
received.patch = patch;
|
||
|
|
received.updatedId = memoryId;
|
||
|
|
return { entry: entry(patch), entries: [entry(patch)] };
|
||
|
|
},
|
||
|
|
remove: async (target, memoryId) => {
|
||
|
|
received.removeTarget = target;
|
||
|
|
received.removedId = memoryId;
|
||
|
|
return { deleted: true, entries: [] };
|
||
|
|
},
|
||
|
|
...overrides.runtime,
|
||
|
|
};
|
||
|
|
|
||
|
|
const app = express();
|
||
|
|
registerAgentMemoryRoutes(app, {
|
||
|
|
agentMemoryRuntime: runtime,
|
||
|
|
isAgentMemoryEnabled: overrides.isAgentMemoryEnabled,
|
||
|
|
});
|
||
|
|
return { app, received };
|
||
|
|
};
|
||
|
|
|
||
|
|
describe('scope resolution', () => {
|
||
|
|
it('reads global scope', async () => {
|
||
|
|
const { app, received } = createApp();
|
||
|
|
|
||
|
|
const response = await request(app).get('/api/agent-memory?scope=global');
|
||
|
|
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
expect(received.readTarget).toEqual({ scope: 'global' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reads project scope with its id', async () => {
|
||
|
|
const { app, received } = createApp();
|
||
|
|
|
||
|
|
await request(app).get('/api/agent-memory?scope=project&projectId=path_abc');
|
||
|
|
|
||
|
|
expect(received.readTarget).toEqual({ scope: 'project', projectId: 'path_abc' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refuses a project scope with no id rather than falling back to global', async () => {
|
||
|
|
const { app, received } = createApp();
|
||
|
|
|
||
|
|
const response = await request(app).get('/api/agent-memory?scope=project');
|
||
|
|
|
||
|
|
expect(response.status).toBe(400);
|
||
|
|
expect(response.body.error).toContain('projectId is required');
|
||
|
|
expect(received.readTarget).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refuses a missing scope', async () => {
|
||
|
|
const { app } = createApp();
|
||
|
|
|
||
|
|
const response = await request(app).get('/api/agent-memory');
|
||
|
|
|
||
|
|
expect(response.status).toBe(400);
|
||
|
|
expect(response.body.error).toContain('scope must be');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refuses a delete with no scope before touching the store', async () => {
|
||
|
|
const { app, received } = createApp();
|
||
|
|
|
||
|
|
const response = await request(app).delete('/api/agent-memory/mem-1');
|
||
|
|
|
||
|
|
expect(response.status).toBe(400);
|
||
|
|
expect(received.removedId).toBeUndefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('both scopes at once', () => {
|
||
|
|
it('returns global and project together', async () => {
|
||
|
|
const { app, received } = createApp();
|
||
|
|
|
||
|
|
const response = await request(app).get('/api/agent-memory/all?projectId=path_abc');
|
||
|
|
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
expect(received.readAllProjectId).toBe('path_abc');
|
||
|
|
expect(response.body.global).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reads global alone when no project is open', async () => {
|
||
|
|
const { app, received } = createApp();
|
||
|
|
|
||
|
|
await request(app).get('/api/agent-memory/all');
|
||
|
|
|
||
|
|
expect(received.readAllProjectId).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('failures', () => {
|
||
|
|
it('reports malformed storage as a server error', async () => {
|
||
|
|
const { app } = createApp({
|
||
|
|
runtime: {
|
||
|
|
read: async () => { throw new Error('Stored agent memory is malformed'); },
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const response = await request(app).get('/api/agent-memory?scope=global');
|
||
|
|
|
||
|
|
expect(response.status).toBe(500);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports a bad project id as a client error', async () => {
|
||
|
|
const { app } = createApp({
|
||
|
|
runtime: {
|
||
|
|
read: async () => { throw new Error('projectId contains unsupported characters'); },
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const response = await request(app).get('/api/agent-memory?scope=project&projectId=..');
|
||
|
|
|
||
|
|
expect(response.status).toBe(400);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('corrections', () => {
|
||
|
|
it('patches a memory from a JSON body', async () => {
|
||
|
|
// This route is the only one here that carries a body, so it is the only
|
||
|
|
// one that needs its own parser — and the only place that can prove it.
|
||
|
|
const { app, received } = createApp();
|
||
|
|
|
||
|
|
const response = await request(app)
|
||
|
|
.patch('/api/agent-memory/mem-1?scope=global')
|
||
|
|
.send({ title: 'Clearer', body: 'Reworded.' });
|
||
|
|
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
expect(received.patch).toEqual({ title: 'Clearer', body: 'Reworded.' });
|
||
|
|
expect(received.updatedId).toBe('mem-1');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects a non-string title', async () => {
|
||
|
|
const { app } = createApp();
|
||
|
|
|
||
|
|
const response = await request(app)
|
||
|
|
.patch('/api/agent-memory/mem-1?scope=global')
|
||
|
|
.send({ title: 42 });
|
||
|
|
|
||
|
|
expect(response.status).toBe(400);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports a missing memory as 404', async () => {
|
||
|
|
const { app } = createApp({ runtime: { update: async () => null } });
|
||
|
|
|
||
|
|
const response = await request(app)
|
||
|
|
.patch('/api/agent-memory/nope?scope=global')
|
||
|
|
.send({ body: 'x' });
|
||
|
|
|
||
|
|
expect(response.status).toBe(404);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('delete', () => {
|
||
|
|
it('deletes the named memory in the named scope', async () => {
|
||
|
|
const { app, received } = createApp();
|
||
|
|
|
||
|
|
const response = await request(app).delete('/api/agent-memory/mem-1?scope=global');
|
||
|
|
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
expect(received.removedId).toBe('mem-1');
|
||
|
|
expect(received.removeTarget).toEqual({ scope: 'global' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports a missing memory as 404', async () => {
|
||
|
|
const { app } = createApp({ runtime: { remove: async () => ({ deleted: false, entries: [] }) } });
|
||
|
|
|
||
|
|
const response = await request(app).delete('/api/agent-memory/nope?scope=global');
|
||
|
|
|
||
|
|
expect(response.status).toBe(404);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('the settings toggle disables the surface, not just its UI', () => {
|
||
|
|
it('flags the disabled answer so a deleted entry cannot be mistaken for it', async () => {
|
||
|
|
// Both answer 404. Without the flag a client would report one memory the
|
||
|
|
// user just deleted as the whole feature being switched off.
|
||
|
|
const off = createApp({ isAgentMemoryEnabled: () => false });
|
||
|
|
const missing = createApp({ runtime: { remove: async () => ({ deleted: false, entries: [] }) } });
|
||
|
|
|
||
|
|
const disabled = await request(off.app).get('/api/agent-memory?scope=global');
|
||
|
|
const notFound = await request(missing.app).delete('/api/agent-memory/nope?scope=global');
|
||
|
|
|
||
|
|
expect(disabled.status).toBe(404);
|
||
|
|
expect(disabled.body.disabled).toBe(true);
|
||
|
|
expect(notFound.status).toBe(404);
|
||
|
|
expect(notFound.body.disabled).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refuses reads while memory is off', async () => {
|
||
|
|
const { app, received } = createApp({ isAgentMemoryEnabled: () => false });
|
||
|
|
|
||
|
|
const response = await request(app).get('/api/agent-memory?scope=global');
|
||
|
|
|
||
|
|
expect(response.status).toBe(404);
|
||
|
|
expect(received.readTarget).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refuses deletes from a stale client while memory is off', async () => {
|
||
|
|
const { app, received } = createApp({ isAgentMemoryEnabled: () => false });
|
||
|
|
|
||
|
|
const response = await request(app).delete('/api/agent-memory/mem-1?scope=global');
|
||
|
|
|
||
|
|
expect(response.status).toBe(404);
|
||
|
|
expect(received.removedId).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('serves normally while memory is on', async () => {
|
||
|
|
const { app } = createApp({ isAgentMemoryEnabled: () => true });
|
||
|
|
|
||
|
|
expect((await request(app).get('/api/agent-memory?scope=global')).status).toBe(200);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('honours a gate that resolves asynchronously', async () => {
|
||
|
|
// The real gate reads the settings file. A synchronous truthiness test on
|
||
|
|
// its promise would leave the surface open with memory turned off.
|
||
|
|
const { app, received } = createApp({ isAgentMemoryEnabled: async () => false });
|
||
|
|
|
||
|
|
const response = await request(app).get('/api/agent-memory?scope=global');
|
||
|
|
|
||
|
|
expect(response.status).toBe(404);
|
||
|
|
expect(received.readTarget).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('closes the surface when the setting cannot be read', async () => {
|
||
|
|
const { app, received } = createApp({
|
||
|
|
isAgentMemoryEnabled: async () => { throw new Error('settings unreadable'); },
|
||
|
|
});
|
||
|
|
|
||
|
|
const response = await request(app).get('/api/agent-memory?scope=global');
|
||
|
|
|
||
|
|
expect(response.status).toBe(503);
|
||
|
|
expect(received.readTarget).toBeUndefined();
|
||
|
|
});
|
||
|
|
});
|