| |
| |
| |
| |
| |
| |
| |
| |
| 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"); |
| |
| 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!!", |
| 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"); |
| |
| 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 () => { |
| |
| |
| |
| 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}`); |
| } |
| }); |
|
|
| |
|
|
| 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"); |
| |
| assert.equal(r.body.salience, 0.9); |
| assert.equal(r.body.use_count, 0); |
| }); |
|
|
| |
|
|
| 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(); |
| |
| |
| await api("POST", `/api/conversations/${convId}/messages`, { |
| content: `Remembered note ${Date.now()}: long-term memory pipeline coverage`, |
| stream: false, |
| }); |
| |
| 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"); |
| |
| assert.ok(remembered.body.salience >= 0.9); |
| assert.equal(remembered.body.source_message_id, userMsg.id); |
| assert.equal(remembered.body.conversation_id, convId); |
|
|
| |
| 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); |
|
|
| |
| 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"); |
|
|
| |
| 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"); |
| |
| 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"); |
| |
| await api("PATCH", "/api/settings", { |
| long_term_memory: { |
| enabled: true, |
| auto_extract: true, |
| max_facts: 100, |
| max_tokens_per_turn: 2000, |
| }, |
| }); |
| }); |
|
|