b33c943b69
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>
284 lines
9.8 KiB
JavaScript
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);
|
|
});
|