Files
memind/memory-v2-personal-shadow.test.mjs
john b33c943b69 feat(memory-v2): auto-accept candidate memories in active and canary modes
Let Personal Memory candidates pass policy gates without per-user manual review, while keeping shadow mode for explicit human approval.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:16:57 +08:00

284 lines
9.8 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
createPersonalMemoryShadowPipeline,
resolvePersonalShadowConfig,
shouldAutoAcceptCandidate,
} from './memory-v2-personal-shadow.mjs';
import { createMemoryV2 } from './memory-v2.mjs';
test('personal memory shadow config maps active mode to auto review', () => {
const config = resolvePersonalShadowConfig({
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'active',
});
assert.equal(config.enabled, true);
assert.equal(config.requestedMode, 'active');
assert.equal(config.effectiveMode, 'active');
assert.equal(config.autoReviewEnabled, true);
});
test('personal memory shadow config keeps shadow mode for manual review', () => {
const config = resolvePersonalShadowConfig({
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'shadow',
});
assert.equal(config.effectiveMode, 'shadow');
assert.equal(config.autoReviewEnabled, false);
});
test('shouldAutoAcceptCandidate auto accepts explicit requests in canary mode', () => {
const config = resolvePersonalShadowConfig({
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'canary',
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
});
assert.equal(shouldAutoAcceptCandidate({
policyReason: 'explicit_memory_request',
confidence: 0.98,
}, config), true);
assert.equal(shouldAutoAcceptCandidate({
policyReason: 'stable_fact_signal',
confidence: 0.82,
}, config), false);
assert.equal(shouldAutoAcceptCandidate({
policyReason: 'decision_signal',
confidence: 0.9,
}, config), true);
});
test('shadow pipeline auto accepts durable candidates in active mode', async () => {
const saved = [];
const pipeline = createPersonalMemoryShadowPipeline({
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'active',
MEMORY_POLICY_ENABLED: '1',
},
store: {
async saveCandidate(candidate, options = {}) {
saved.push({ candidate, options });
return { inserted: true, status: options.autoAccept ? 'accepted' : 'candidate' };
},
},
});
const result = await pipeline.observeWrite({
userId: 'u1',
sessionId: 's1',
messages: [{ role: 'user', text: '我决定所有生产发布必须从完整 main 分支打包。' }],
});
assert.equal(result.autoReviewed, 1);
assert.equal(result.pendingReview, 0);
assert.equal(saved.length, 1);
assert.equal(saved[0].options.autoAccept, true);
assert.equal(pipeline.getStatus().autoReviewEnabled, true);
assert.equal(pipeline.getStatus().phase, 'auto-review-v1');
});
test('shadow pipeline keeps low-confidence canary candidates pending review', async () => {
const saved = [];
const pipeline = createPersonalMemoryShadowPipeline({
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'canary',
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
},
store: {
async saveCandidate(candidate, options = {}) {
saved.push({ candidate, options });
return { inserted: true, status: options.autoAccept ? 'accepted' : 'candidate' };
},
},
});
await pipeline.observeWrite({
userId: 'u1',
sessionId: 's1',
messages: [{ role: 'user', text: '项目使用 pgvector 作为向量存储。' }],
});
assert.equal(saved.length, 1);
assert.equal(saved[0].options.autoAccept, false);
assert.equal(pipeline.getStatus().health.pendingReview, 1);
});
test('shadow pipeline extracts durable candidates with evidence and deduplicates', async () => {
let clock = 1000;
const pipeline = createPersonalMemoryShadowPipeline({
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'shadow',
MEMORY_POLICY_ENABLED: '1',
MEMORY_POLICY_REQUIRE_EVIDENCE: '1',
MEMORY_CANDIDATE_MIN_IMPORTANCE: '0.7',
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
},
now: () => {
clock += 1;
return clock;
},
});
const input = {
userId: 'u1',
sessionId: 's1',
messages: [
{ role: 'user', text: '我决定所有生产发布必须从完整 main 分支打包。' },
{ role: 'assistant', text: '收到。' },
],
};
const first = await pipeline.observeWrite(input);
const second = await pipeline.observeWrite(input);
assert.equal(first.accepted, 1);
assert.equal(second.accepted, 0);
assert.equal(pipeline.listCandidates().length, 1);
assert.equal(pipeline.listCandidates()[0].memoryType, 'episodic');
assert.equal(pipeline.listCandidates()[0].evidence.sourceId, 's1:0');
assert.equal(pipeline.getStatus().health.deduped, 1);
assert.equal(pipeline.getStatus().injectionEnabled, false);
});
test('shadow policy rejects trivial and sensitive messages', async () => {
const pipeline = createPersonalMemoryShadowPipeline({
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'shadow',
MEMORY_POLICY_REJECT_SENSITIVE: '1',
},
});
const result = await pipeline.observeWrite({
userId: 'u1',
sessionId: 's1',
messages: [
{ role: 'user', text: '谢谢' },
{ role: 'user', text: '请记住 API_KEY=sk-this-is-a-secret-token' },
{ role: 'user', text: '今天天气怎么样?' },
],
});
assert.equal(result.accepted, 0);
assert.equal(result.rejected, 3);
assert.equal(pipeline.listCandidates().length, 0);
});
test('shadow pipeline prefers user-facing displayText over internal prompt content', async () => {
const pipeline = createPersonalMemoryShadowPipeline({
env: { MEMORY_CANDIDATE_ENABLED: '1', MEMORY_CANDIDATE_MODE: 'shadow' },
});
await pipeline.observeWrite({
userId: 'u1',
sessionId: 's1',
messages: [{
role: 'user',
content: [{ type: 'text', text: '[用户身份] 内部前缀\n我决定先测试再发布。' }],
metadata: { displayText: '我决定先测试再发布。' },
}],
});
assert.equal(pipeline.listCandidates().length, 1);
assert.equal(pipeline.listCandidates()[0].content, '我决定先测试再发布。');
assert.doesNotMatch(pipeline.listCandidates()[0].content, /用户身份/);
});
test('shadow pipeline strips protected user identity prefix when displayText is absent', async () => {
const pipeline = createPersonalMemoryShadowPipeline({
env: { MEMORY_CANDIDATE_ENABLED: '1', MEMORY_CANDIDATE_MODE: 'shadow' },
});
await pipeline.observeWrite({
userId: 'u1',
sessionId: 's1',
messages: [{
role: 'user',
content: [{
type: 'text',
text: '[用户身份]\n- 当前登录用户称呼:John\n- 内部提示\n\n我决定先测试再发布。',
}],
}],
});
assert.equal(pipeline.listCandidates().length, 1);
assert.equal(pipeline.listCandidates()[0].content, '我决定先测试再发布。');
});
test('shadow candidate pool remains bounded', async () => {
const pipeline = createPersonalMemoryShadowPipeline({
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'shadow',
MEMORY_CANDIDATE_MAX_PENDING: '2',
},
});
await pipeline.observeWrite({
userId: 'u1',
sessionId: 's1',
messages: [
{ role: 'user', text: '我决定项目一使用完整 main 发布。' },
{ role: 'user', text: '我决定项目二使用完整 main 发布。' },
{ role: 'user', text: '我决定项目三使用完整 main 发布。' },
],
});
assert.equal(pipeline.listCandidates().length, 2);
assert.match(pipeline.listCandidates()[0].content, /项目三/);
});
test('shadow pipeline persists accepted candidates when a store is configured', async () => {
const saved = [];
const pipeline = createPersonalMemoryShadowPipeline({
env: { MEMORY_CANDIDATE_ENABLED: '1', MEMORY_CANDIDATE_MODE: 'shadow' },
store: { async saveCandidate(candidate) { saved.push(candidate); return { inserted: true }; } },
});
await pipeline.observeWrite({
userId: 'u1',
sessionId: 's1',
messages: [{ role: 'user', text: '我的长期目标是建设一个持续成长的 Personal Agent。' }],
});
assert.equal(saved.length, 1);
assert.equal(saved[0].memoryType, 'goal');
assert.equal(pipeline.getStatus().persistence, 'mysql');
});
test('Memory V2 does not wait for the shadow pipeline before returning legacy write result', async () => {
let releaseShadow;
const shadowBlocked = new Promise((resolve) => { releaseShadow = resolve; });
const memory = createMemoryV2({
policy: { enabled: true, eventLogEnabled: true, failOpen: true, backend: 'legacy' },
backends: [{
name: 'legacy-conversation-memory',
isAvailable: () => true,
async write() { return { saved: 1, analyzed: 0, memories: 0 }; },
}],
personalShadowPipeline: {
config: { enabled: true },
observeWrite: () => shadowBlocked,
getStatus: () => ({ enabled: true }),
},
});
const result = await Promise.race([
memory.write({ userId: 'u1', sessionId: 's1', messages: [] }),
new Promise((_, reject) => setTimeout(() => reject(new Error('write waited for shadow')), 50)),
]);
assert.equal(result.saved, 1);
releaseShadow();
});
test('Memory V2 exposes a shadow-only observation entry without calling legacy write', async () => {
let legacyWrites = 0;
const observed = [];
const memory = createMemoryV2({
policy: { enabled: true, eventLogEnabled: true, failOpen: true, backend: 'legacy' },
backends: [{
name: 'legacy-conversation-memory',
isAvailable: () => true,
async write() { legacyWrites += 1; return { saved: 1 }; },
}],
personalShadowPipeline: {
config: { enabled: true },
async observeWrite(input) { observed.push(input); return { accepted: 1 }; },
getStatus: () => ({ enabled: true }),
},
});
const result = await memory.observePersonalMemory({
userId: 'u1',
sessionId: 's1',
messages: [{ role: 'user', text: '我决定先完成测试。' }],
});
assert.equal(result.accepted, 1);
assert.equal(observed.length, 1);
assert.equal(legacyWrites, 0);
});