doatlas-2 / artifacts /api-server /src /lib /__tests__ /memory.test.mjs
Iostream-Li's picture
Add files using upload-large-folder tool
ff78003 verified
// Real integration tests for the long-term memory service. These hit the
// live API server (which exercises lib/memory.ts against the real Postgres
// instance) so we cover dedupe, archive/eviction semantics, ranker
// budgeting, kind validation, content-hash dedupe, archive/unarchive, and
// hard-cap enforcement.
//
// Run with the api-server workflow already up:
// node --test artifacts/api-server/src/lib/__tests__/memory.test.mjs
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
const BASE = process.env.DOATLAS_API_BASE || "http://localhost:8080";
const EMAIL = process.env.DOATLAS_TEST_EMAIL || "alice@doatlas.cn";
const PASSWORD = process.env.DOATLAS_TEST_PASSWORD || "pass1234";
let token = "";
async function api(method, path, body) {
const r = await fetch(`${BASE}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await r.text();
let json;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = text;
}
return { status: r.status, body: json };
}
before(async () => {
const login = await api("POST", "/api/auth/login", {
email: EMAIL,
password: PASSWORD,
});
assert.equal(login.status, 200, `login failed: ${JSON.stringify(login)}`);
token = login.body.access_token;
assert.ok(token, "no access_token in login response");
// start clean and enable memory
await api("DELETE", "/api/memory/facts");
await api("PATCH", "/api/settings", {
long_term_memory: {
enabled: true,
auto_extract: true,
max_facts: 100,
max_tokens_per_turn: 2000,
},
});
});
after(async () => {
await api("DELETE", "/api/memory/facts");
await api("PATCH", "/api/settings", {
long_term_memory: { enabled: false },
});
});
test("kind validation: all six spec kinds accepted, others rejected", async () => {
for (const kind of [
"preference",
"fact",
"interest",
"domain",
"terminology",
"summary",
]) {
const r = await api("POST", "/api/memory/facts", {
kind,
content: `kindtest ${kind} ${Date.now()}`,
});
assert.ok(r.status === 200 || r.status === 201, `kind ${kind} status ${r.status}`);
assert.equal(r.body.kind, kind);
assert.equal(r.body.archived, false);
}
for (const bad of ["profile", "context", "workflow", "other"]) {
const r = await api("POST", "/api/memory/facts", {
kind: bad,
content: "x",
});
assert.equal(r.status, 400, `bad kind '${bad}' should 400, got ${r.status}`);
}
});
test("dedupe: re-emitting same content bumps salience + use_count, no new row", async () => {
await api("DELETE", "/api/memory/facts");
const a = await api("POST", "/api/memory/facts", {
kind: "preference",
content: "User prefers concise bullet-point answers",
salience: 0.5,
});
assert.equal(a.status, 201);
const id = a.body.id;
const b = await api("POST", "/api/memory/facts", {
kind: "preference",
content: "user prefers CONCISE bullet point answers!!", // same after normalize
salience: 0.5,
});
assert.equal(b.body.id, id, "expected dedupe hit, got a new id");
assert.ok(b.body.salience > a.body.salience, "salience should bump");
assert.ok(b.body.use_count >= a.body.use_count + 1, "use_count should increment");
});
test("content-hash dedupe also catches punctuation-only differences", async () => {
await api("DELETE", "/api/memory/facts");
const a = await api("POST", "/api/memory/facts", {
kind: "fact",
content: "User's lab focuses on KRAS G12C in NSCLC.",
});
const b = await api("POST", "/api/memory/facts", {
kind: "fact",
content: "user s lab focuses on kras g12c in nsclc",
});
assert.equal(b.body.id, a.body.id, "hash dedupe must collapse punctuation variants");
});
test("archive: archived facts disappear from default list, return on include_archived", async () => {
await api("DELETE", "/api/memory/facts");
const created = await api("POST", "/api/memory/facts", {
kind: "interest",
content: "Likes single-cell RNA-seq workflows",
});
const id = created.body.id;
const arch = await api("POST", `/api/memory/facts/${id}/archive`);
assert.equal(arch.status, 200);
assert.equal(arch.body.archived, true);
const list = await api("GET", "/api/memory/facts");
assert.ok(!list.body.items.find((f) => f.id === id), "archived must be hidden");
assert.equal(list.body.archived_count, 1);
const listAll = await api("GET", "/api/memory/facts?include_archived=true");
assert.ok(listAll.body.items.find((f) => f.id === id), "include_archived must surface it");
const unarch = await api("POST", `/api/memory/facts/${id}/unarchive`);
assert.equal(unarch.body.archived, false);
});
test("estimated_tokens_per_turn reported and capped at max_tokens_per_turn", async () => {
await api("DELETE", "/api/memory/facts");
const empty = await api("GET", "/api/memory/facts");
assert.ok(typeof empty.body.estimated_tokens_per_turn === "number");
// empty store: header+instruction overhead only
assert.ok(empty.body.estimated_tokens_per_turn < 100);
for (let i = 0; i < 5; i++) {
await api("POST", "/api/memory/facts", {
kind: "fact",
content: `Distinct lab fact number ${i}: ${"x".repeat(120)}`,
});
}
const filled = await api("GET", "/api/memory/facts");
assert.ok(
filled.body.estimated_tokens_per_turn > empty.body.estimated_tokens_per_turn,
"estimate must grow when facts exist",
);
assert.ok(
filled.body.estimated_tokens_per_turn <= filled.body.max_tokens_per_turn,
"estimate must never exceed configured cap",
);
});
test("hard caps surfaced verbatim (100 / 2000 / 5)", async () => {
const r = await api("GET", "/api/memory/facts");
assert.equal(r.body.caps.facts, 100);
assert.equal(r.body.caps.tokensPerTurn, 2000);
assert.equal(r.body.caps.newPerTurn, 5);
});
test("server-side search + pagination via ?q= and ?limit/?offset", async () => {
await api("DELETE", "/api/memory/facts");
for (let i = 0; i < 6; i++) {
await api("POST", "/api/memory/facts", {
kind: i % 2 ? "fact" : "preference",
content: `Searchable lab item alpha ${i}`,
});
}
await api("POST", "/api/memory/facts", {
kind: "interest",
content: "Unrelated zebrafish tag",
});
const all = await api("GET", "/api/memory/facts?q=alpha");
assert.equal(all.body.total, 6);
assert.equal(all.body.items.length, 6);
const page1 = await api("GET", "/api/memory/facts?q=alpha&limit=2&offset=0");
assert.equal(page1.body.items.length, 2);
assert.equal(page1.body.total, 6);
const page2 = await api("GET", "/api/memory/facts?q=alpha&limit=2&offset=2");
assert.equal(page2.body.items.length, 2);
assert.notDeepEqual(
page1.body.items.map((x) => x.id),
page2.body.items.map((x) => x.id),
);
});
test("text-trigger remember regex matches CJK + English phrases", async () => {
// We can't easily fire a chat turn here without a model, so we validate
// the published trigger regex shape directly. The route uses the same
// pattern verbatim.
const RE = /(记住这条|记住此回复|记住这个|请记住|帮我记住|remember this(?![a-z]))/i;
for (const s of [
"记住这条",
"请记住下面这段总结",
"帮我记住,患者代号 P001",
"ok, remember this please",
"Remember this.",
]) {
assert.ok(RE.test(s), `should match: ${s}`);
}
for (const s of ["remember thistle", "记住一下", "no triggers here"]) {
assert.ok(!RE.test(s), `should not match: ${s}`);
}
});
// ---------- POST /api/memory/facts (extra coverage)
test("POST /api/memory/facts: missing/empty content rejected with bad_request", async () => {
const noField = await api("POST", "/api/memory/facts", { kind: "fact" });
assert.equal(noField.status, 400);
assert.equal(noField.body.error_code, "bad_request");
const blank = await api("POST", "/api/memory/facts", { content: " " });
assert.equal(blank.status, 400);
assert.equal(blank.body.error_code, "bad_request");
});
test("POST /api/memory/facts: persists source=manual + manual-default salience 0.9", async () => {
await api("DELETE", "/api/memory/facts");
const r = await api("POST", "/api/memory/facts", {
kind: "preference",
content: `Manual fact ${Date.now()} likes structured outputs`,
});
assert.equal(r.status, 201);
assert.equal(r.body.source, "manual");
// Manual creates default to high salience (0.9) per lib/memory.ts.
assert.equal(r.body.salience, 0.9);
assert.equal(r.body.use_count, 0);
});
// ---------- POST /conversations/:id/messages/:mid/remember
async function createConversation() {
const r = await api("POST", "/api/conversations", { title: "memory-test" });
assert.ok(r.status === 200 || r.status === 201, `create conv: ${JSON.stringify(r)}`);
return r.body.id;
}
test("POST /messages/:mid/remember: persists target text as a manual summary", async () => {
await api("DELETE", "/api/memory/facts");
const convId = await createConversation();
// Post a user message and grab its id (the user message is always
// persisted regardless of model availability).
await api("POST", `/api/conversations/${convId}/messages`, {
content: `Remembered note ${Date.now()}: long-term memory pipeline coverage`,
stream: false,
});
// Find the user message id — it lives in the conversation history.
const list = await api("GET", `/api/conversations/${convId}/messages`);
assert.ok(Array.isArray(list.body.items) || Array.isArray(list.body), "messages list");
const items = Array.isArray(list.body.items) ? list.body.items : list.body;
const userMsg = items.find((m) => m.role === "user");
assert.ok(userMsg, "expected at least one user message");
const remembered = await api(
"POST",
`/api/conversations/${convId}/messages/${userMsg.id}/remember`,
{},
);
assert.ok(
remembered.status === 201 || remembered.status === 200,
`remember status ${remembered.status}: ${JSON.stringify(remembered.body)}`,
);
assert.equal(remembered.body.source, "manual");
assert.equal(remembered.body.kind, "summary");
// Manual remember uses high salience by design.
assert.ok(remembered.body.salience >= 0.9);
assert.equal(remembered.body.source_message_id, userMsg.id);
assert.equal(remembered.body.conversation_id, convId);
// Re-issuing the same remember must dedupe (no new row).
const again = await api(
"POST",
`/api/conversations/${convId}/messages/${userMsg.id}/remember`,
{},
);
assert.equal(again.body.id, remembered.body.id, "remember must be idempotent");
assert.ok(again.body.use_count >= remembered.body.use_count + 1);
// The fact must be visible in the listing.
const list2 = await api("GET", "/api/memory/facts");
assert.ok(list2.body.items.find((f) => f.id === remembered.body.id));
});
test("POST /messages/:mid/remember: explicit content overrides the message text", async () => {
await api("DELETE", "/api/memory/facts");
const convId = await createConversation();
await api("POST", `/api/conversations/${convId}/messages`, {
content: "raw user text that we will NOT use",
stream: false,
});
const list = await api("GET", `/api/conversations/${convId}/messages`);
const items = Array.isArray(list.body.items) ? list.body.items : list.body;
const userMsg = items.find((m) => m.role === "user");
const overrideText = `Override-only memory ${Date.now()}`;
const remembered = await api(
"POST",
`/api/conversations/${convId}/messages/${userMsg.id}/remember`,
{ content: overrideText, kind: "preference" },
);
assert.ok(remembered.status === 200 || remembered.status === 201);
assert.equal(remembered.body.content, overrideText);
assert.equal(remembered.body.kind, "preference");
});
test("POST /messages/:mid/remember: 404 for unknown conversation or message", async () => {
const convId = await createConversation();
const list = await api("GET", `/api/conversations/${convId}/messages`);
const items = Array.isArray(list.body.items) ? list.body.items : list.body;
const realMsg = items.find((m) => m.role === "user");
const badConv = await api(
"POST",
`/api/conversations/conv_doesnotexist/messages/${realMsg?.id ?? "m_x"}/remember`,
{},
);
assert.equal(badConv.status, 404);
assert.equal(badConv.body.error_code, "not_found");
const badMsg = await api(
"POST",
`/api/conversations/${convId}/messages/msg_doesnotexist/remember`,
{},
);
assert.equal(badMsg.status, 404);
assert.equal(badMsg.body.error_code, "not_found");
});
test("POST /messages/:mid/remember: returns 409 when memory is disabled", async () => {
const convId = await createConversation();
await api("POST", `/api/conversations/${convId}/messages`, {
content: "disabled-memory remember probe",
stream: false,
});
const list = await api("GET", `/api/conversations/${convId}/messages`);
const items = Array.isArray(list.body.items) ? list.body.items : list.body;
const userMsg = items.find((m) => m.role === "user");
await api("PATCH", "/api/settings", {
long_term_memory: { enabled: false },
});
const blocked = await api(
"POST",
`/api/conversations/${convId}/messages/${userMsg.id}/remember`,
{},
);
assert.equal(blocked.status, 409);
assert.equal(blocked.body.error_code, "memory_disabled");
// Re-enable for downstream tests.
await api("PATCH", "/api/settings", {
long_term_memory: {
enabled: true,
auto_extract: true,
max_facts: 100,
max_tokens_per_turn: 2000,
},
});
});
test("GET /api/memory/status: enabled returns the exact contract shape", async () => {
await api("PATCH", "/api/settings", {
long_term_memory: {
enabled: true,
auto_extract: true,
max_facts: 100,
max_tokens_per_turn: 2000,
},
});
const r = await api("GET", "/api/memory/status");
assert.equal(r.status, 200);
assert.ok(r.body && typeof r.body === "object" && !Array.isArray(r.body));
const expectedTopKeys = [
"auto_extract",
"count",
"enabled",
"hard_caps",
"max_facts",
"max_tokens_per_turn",
];
assert.deepEqual(
Object.keys(r.body).sort(),
expectedTopKeys,
"top-level status keys must match the published contract exactly",
);
assert.equal(r.body.enabled, true);
assert.equal(typeof r.body.auto_extract, "boolean");
assert.equal(typeof r.body.max_facts, "number");
assert.equal(typeof r.body.max_tokens_per_turn, "number");
assert.equal(typeof r.body.count, "number");
assert.ok(r.body.hard_caps && typeof r.body.hard_caps === "object");
const expectedCapKeys = ["facts", "new_per_turn", "tokens_per_turn"];
assert.deepEqual(
Object.keys(r.body.hard_caps).sort(),
expectedCapKeys,
"hard_caps keys must match the published contract exactly",
);
assert.equal(typeof r.body.hard_caps.facts, "number");
assert.equal(typeof r.body.hard_caps.tokens_per_turn, "number");
assert.equal(typeof r.body.hard_caps.new_per_turn, "number");
});
test("GET /api/memory/status: disabled still returns the full shape", async () => {
await api("PATCH", "/api/settings", {
long_term_memory: { enabled: false },
});
const r = await api("GET", "/api/memory/status");
assert.equal(r.status, 200);
const expectedTopKeys = [
"auto_extract",
"count",
"enabled",
"hard_caps",
"max_facts",
"max_tokens_per_turn",
];
assert.deepEqual(
Object.keys(r.body).sort(),
expectedTopKeys,
"disabled status must still carry every contract key",
);
assert.equal(r.body.enabled, false);
assert.equal(typeof r.body.auto_extract, "boolean");
assert.equal(typeof r.body.max_facts, "number");
assert.equal(typeof r.body.max_tokens_per_turn, "number");
assert.equal(typeof r.body.count, "number");
const expectedCapKeys = ["facts", "new_per_turn", "tokens_per_turn"];
assert.deepEqual(
Object.keys(r.body.hard_caps).sort(),
expectedCapKeys,
"hard_caps keys must match even when disabled",
);
assert.equal(typeof r.body.hard_caps.facts, "number");
assert.equal(typeof r.body.hard_caps.tokens_per_turn, "number");
assert.equal(typeof r.body.hard_caps.new_per_turn, "number");
// Restore enabled state for the next test.
await api("PATCH", "/api/settings", {
long_term_memory: {
enabled: true,
auto_extract: true,
max_facts: 100,
max_tokens_per_turn: 2000,
},
});
});
test("disabled memory: status reflects disabled and writes are blocked", async () => {
await api("PATCH", "/api/settings", {
long_term_memory: { enabled: false },
});
const status = await api("GET", "/api/memory/status");
assert.equal(status.body.enabled, false);
const blocked = await api("POST", "/api/memory/facts", {
kind: "fact",
content: "should not persist while memory is off",
});
assert.equal(blocked.status, 409, "writes must be blocked when disabled");
assert.equal(blocked.body.error_code, "memory_disabled");
// Re-enable for cleanup determinism in `after`.
await api("PATCH", "/api/settings", {
long_term_memory: {
enabled: true,
auto_extract: true,
max_facts: 100,
max_tokens_per_turn: 2000,
},
});
});