Files
metona-ai-desktop/electron/harness/memory/__tests__/memory-manager.test.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

1060 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MemoryManager 测试(v0.2.0 TF-IDF 检索 —— 此前零测试)
*
* 锁定记忆管理器核心契约:
* 1. tokenize:英文词/CJK bigram/单字 CJK 子句/跨标点不组合/小写归一
* 2. tfidfSearch:余弦打分排序/时间衰减(30 天半衰期)/重要性权重/type 过滤/topK
* 3. searchTF-IDF 无命中回退 LIKE/ESCAPE 转义/topK/threshold/sessionId 无效
* 4. store:三层记忆/importance 默认计算/semantic contentHash 去重/working 覆盖/tf_cache
* 5. working memory CRUDget/set/clear
* 6. cleanupExpired
*
* 运行要求:better-sqlite3 为 Electron ABI 构建(test:electron 模式),系统 Node 下自动跳过。
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
let dbAvailable = true;
let Database: typeof import('better-sqlite3');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
Database = require('better-sqlite3');
const probe = new Database(':memory:');
probe.close();
} catch {
dbAvailable = false;
}
import { MemoryManager } from '../manager';
// 与 DatabaseService.createTables 一致的记忆三表 schema(含 tf_cache / embedding /
// embedding_model 列 —— v0.8.2 P3-1 迁移 14 对齐)
function createMemorySchema(db: any): void {
db.exec(`
CREATE TABLE episodic_memories (
id TEXT PRIMARY KEY,
session_id TEXT,
content TEXT NOT NULL,
summary TEXT,
source TEXT NOT NULL,
importance REAL DEFAULT 0.5,
created_at INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER,
tf_cache TEXT,
embedding BLOB,
embedding_model TEXT
);
CREATE TABLE semantic_memories (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
category TEXT,
confidence REAL DEFAULT 0.8,
source_session TEXT,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0,
access_count INTEGER DEFAULT 0,
tf_cache TEXT,
embedding BLOB,
embedding_model TEXT
);
CREATE TABLE working_memories (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT 0,
tf_cache TEXT,
UNIQUE(session_id, task_id, key)
);
`);
}
describe.skipIf(!dbAvailable)('MemoryManager — tokenize 分词', () => {
let mgr: MemoryManager;
beforeAll(() => {
const db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
});
afterAll(() => {
(mgr as unknown as { getDB(): any }).getDB().close();
});
// 通过 store + 读取 tf_cache 间接验证分词(tokenize 为模块私有)
// tf_cache = JSON.stringify(tokenize(content + ' ' + summary))
function tokensOf(content: string, summary = ''): string[] {
mgr.store({
type: 'episodic',
content,
summary,
source: 'user_input',
importance: 0.7,
sessionId: 'tok',
});
const db = (mgr as unknown as { getDB(): any }).getDB();
const row = db
.prepare('SELECT tf_cache FROM episodic_memories ORDER BY rowid DESC LIMIT 1')
.get() as { tf_cache: string };
return JSON.parse(row.tf_cache) as string[];
}
it('英文小写归一且按单词切分(含数字与连字符/下划线)', () => {
const tokens = tokensOf('Hello World AI-agent v2.0');
expect(tokens).toContain('hello');
expect(tokens).toContain('world');
expect(tokens).toContain('ai-agent');
expect(tokens).toContain('v2');
expect(tokens).not.toContain('Hello'); // 大小写归一
expect(tokens).not.toContain('hello world'); // 不产生英文 bigram
});
it('CJK 子句内 bigram:两字子句整体为一个 bigram', () => {
const tokens = tokensOf('学习');
expect(tokens).toContain('学习');
});
it('CJK 长子句产生相邻 bigram(连续窗口)', () => {
const tokens = tokensOf('深度学习模型');
expect(tokens).toContain('深度');
expect(tokens).toContain('度学');
expect(tokens).toContain('学习');
expect(tokens).toContain('习模');
expect(tokens).toContain('模型');
});
it('CJK 三字文本产生两个相邻 bigram(不含整串)', () => {
const tokens = tokensOf('人工智能');
expect(tokens).toContain('人工');
expect(tokens).toContain('工智');
expect(tokens).toContain('智能');
expect(tokens).not.toContain('人工智能');
});
it('跨标点边界不产生噪声 bigramv0.3.18 修复)', () => {
const tokens = tokensOf('开发规范。下一句');
expect(tokens).not.toContain('范。');
expect(tokens).not.toContain('。下');
expect(tokens).toContain('开发');
expect(tokens).toContain('规范');
expect(tokens).toContain('下一');
expect(tokens).toContain('一句');
});
it('单字 CJK 子句补 unigram(避免单字文档无 token', () => {
const tokens = tokensOf('AI,好');
expect(tokens).toContain('好');
});
it('英文标点 .;!?() 同样切分子句', () => {
const tokens = tokensOf('第一句.第二句;第三句');
expect(tokens).not.toContain('句.');
expect(tokens).not.toContain('.第');
expect(tokens).not.toContain('句;');
expect(tokens).toContain('第一');
});
it('数字/纯符号文本产生空 token 列表(search 返回空)', () => {
const tokens = tokensOf('12345 !@#');
expect(Array.isArray(tokens)).toBe(true);
// v0.8.0 P3: 补强断言 —— 旧断言只验数组类型未验为空
expect(tokens).toHaveLength(0);
});
});
describe.skipIf(!dbAvailable)('MemoryManager — store 三层记忆', () => {
let db: any;
let mgr: MemoryManager;
beforeEach(() => {
db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
});
afterEach(() => {
try {
db.close();
} catch {
/* ignore */
}
});
it('episodic store:默认 importance 按 source 计算(user_input=0.7', () => {
// store() 的类型签名要求 importance 必填,但运行时 `item.importance ?? calculateImportance`
// 视其为可缺省;本用例锁定默认计算路径,故显式传 undefined 触发回退(见 manager.ts store())。
const id = mgr.store({
type: 'episodic',
content: '用户说喜欢深色主题',
source: 'user_input',
sessionId: 's1',
importance: undefined as unknown as number,
});
const row = db.prepare('SELECT * FROM episodic_memories WHERE id = ?').get(id) as Record<
string,
unknown
>;
expect(row.content).toBe('用户说喜欢深色主题');
expect(row.session_id).toBe('s1');
expect(row.source).toBe('user_input');
expect(row.importance).toBe(0.7); // 0.5 + 0.2
});
it('importance 显式传入时不被覆盖', () => {
const id = mgr.store({
type: 'episodic',
content: '重要事实',
source: 'agent_thought',
importance: 0.95,
});
const row = db.prepare('SELECT importance FROM episodic_memories WHERE id = ?').get(id) as {
importance: number;
};
expect(row.importance).toBe(0.95);
});
it('episodic importance 计算:tool_result=0.6、长内容 +0.1、上限 1', () => {
// 同上:显式 undefined 触发运行时默认计算路径
const id = mgr.store({
type: 'episodic',
content: 'x'.repeat(250),
source: 'tool_result',
importance: undefined as unknown as number,
});
const row = db.prepare('SELECT importance FROM episodic_memories WHERE id = ?').get(id) as {
importance: number;
};
expect(row.importance).toBe(0.7); // 0.5 + 0.1(tool_result) + 0.1(长内容)
});
it('episodic store 写入 tf_cacheJSON token 数组)', () => {
mgr.store({ type: 'episodic', content: '缓存分词验证', source: 'user_input', importance: 0.7 });
const row = db
.prepare('SELECT tf_cache FROM episodic_memories ORDER BY rowid DESC LIMIT 1')
.get() as { tf_cache: string };
const tokens = JSON.parse(row.tf_cache) as string[];
expect(tokens).toContain('缓存');
expect(tokens).toContain('分词');
expect(tokens).toContain('验证');
});
it('semantic store:未提供 summary 时用 contentHash 作为 key(内容去重)', () => {
mgr.store({
type: 'semantic',
content: '项目采用 SQLite',
source: 'imported',
importance: 0.5,
});
mgr.store({
type: 'semantic',
content: '项目采用 SQLite',
source: 'imported',
importance: 0.5,
});
const rows = db.prepare('SELECT * FROM semantic_memories').all() as Array<{
key: string;
value: string;
}>;
expect(rows).toHaveLength(1); // 相同内容 REPLACE 为一条
expect(rows[0].value).toBe('项目采用 SQLite');
expect(rows[0].key).not.toContain('mem_'); // key 是 content hash 而非随机 id
});
it('semantic store:提供 summary 时以 summary 为 key(更新已有记忆)', () => {
mgr.store({
type: 'semantic',
content: '旧值',
summary: '用户昵称',
source: 'imported',
importance: 0.5,
});
mgr.store({
type: 'semantic',
content: '新值',
summary: '用户昵称',
source: 'imported',
importance: 0.5,
});
const rows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{
value: string;
}>;
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe('新值');
});
it('semantic storecategory 固定为 general、confidence=importance', () => {
const id = mgr.store({
type: 'semantic',
content: '事实',
source: 'imported',
importance: 0.88,
});
const row = db
.prepare('SELECT category, confidence FROM semantic_memories WHERE id = ?')
.get(id) as { category: string; confidence: number };
expect(row.category).toBe('general');
expect(row.confidence).toBe(0.88);
});
it('working store:未提供 summary 用 contentHash 为 key,同内容同 session 覆盖', () => {
mgr.store({
type: 'working',
content: '正在处理的文件',
sessionId: 's1',
importance: 0.5,
source: 'agent_thought',
});
mgr.store({
type: 'working',
content: '正在处理的文件',
sessionId: 's1',
importance: 0.5,
source: 'agent_thought',
});
const rows = db
.prepare("SELECT * FROM working_memories WHERE session_id = 's1'")
.all() as unknown[];
expect(rows).toHaveLength(1);
});
it('working store:不同 session 同内容互不影响', () => {
mgr.store({
type: 'working',
content: '共享内容',
sessionId: 's1',
importance: 0.5,
source: 'agent_thought',
});
mgr.store({
type: 'working',
content: '共享内容',
sessionId: 's2',
importance: 0.5,
source: 'agent_thought',
});
const rows = db.prepare('SELECT * FROM working_memories').all() as unknown[];
expect(rows).toHaveLength(2);
});
it('未知 type 抛错而非静默失败(v0.3.0 修复)', () => {
expect(() =>
mgr.store({
type: 'bogus' as 'episodic',
content: 'x',
source: 'user_input',
importance: 0.7,
}),
).toThrow(/Unknown memory type/);
});
it('store 使 IDF 缓存失效(cacheUpdatedAt 重置)', async () => {
// v0.7.4 强化断言: 若 IDF 缓存未失效/检索不扫描新行,store 后 search 返回空即失败。
mgr.store({ type: 'episodic', content: 'hello world', source: 'user_input', importance: 0.7 });
await mgr.search('hello'); // 建立 IDF 缓存
// 再 store 一条 → 缓存应失效,新内容可被检索
mgr.store({
type: 'episodic',
content: 'another content',
source: 'user_input',
importance: 0.7,
});
const results = await mgr.search('another');
expect(results.some((r) => r.content === 'another content')).toBe(true);
// 双向验证:缓存重建后旧内容仍可检索(不因重建丢失)
const oldResults = await mgr.search('hello');
expect(oldResults.some((r) => r.content === 'hello world')).toBe(true);
});
it('返回的 id 可被后续读取(id 稳定)', () => {
const id = mgr.store({
type: 'episodic',
content: 'stable',
source: 'user_input',
importance: 0.7,
});
const row = db.prepare('SELECT id FROM episodic_memories WHERE id = ?').get(id);
expect(row).toBeDefined();
});
});
describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', () => {
let db: any;
let mgr: MemoryManager;
let clock: number;
beforeEach(() => {
db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
// 固定"现在",避免真实时间漂移造成 flaky
clock = Date.now();
vi.spyOn(Date, 'now').mockImplementation(() => clock);
});
afterEach(() => {
vi.restoreAllMocks();
try {
db.close();
} catch {
/* ignore */
}
});
it('相同关键词:得分高者排前(内容重复度越高得分越高)', async () => {
mgr.store({
type: 'episodic',
content: 'memory hello world test',
source: 'user_input',
importance: 0.7,
});
mgr.store({ type: 'episodic', content: 'hello world', source: 'user_input', importance: 0.7 });
mgr.store({
type: 'episodic',
content: '完全无关的内容',
source: 'user_input',
importance: 0.7,
});
const results = await mgr.search('hello world');
expect(results.length).toBeGreaterThan(0);
expect(results.every((r) => r.score > 0)).toBe(true);
// 两条命中的按分数降序
const scores = results.map((r) => r.score);
expect([...scores].sort((a, b) => b - a)).toEqual(scores);
});
it('时间衰减:同内容越新得分越高(30 天半衰期)', async () => {
mgr.store({
type: 'episodic',
content: '关键 bug 修复方案',
source: 'user_input',
importance: 0.7,
});
const newId = mgr.store({
type: 'episodic',
content: '关键 bug 修复方案',
source: 'user_input',
importance: 0.7,
});
// 使旧记录过期 30 天
const oldId = mgr.store({
type: 'episodic',
content: '关键 bug 修复方案',
source: 'user_input',
importance: 0.7,
});
// 直接改 created_at:两条旧、一条新
db.prepare('UPDATE episodic_memories SET created_at = ? WHERE id = ?').run(
clock - 60 * 24 * 3600 * 1000, // 60 天前
oldId,
);
db.prepare('UPDATE episodic_memories SET created_at = ? WHERE id = ?').run(
clock - 30 * 24 * 3600 * 1000, // 30 天前(衰减 0.5
newId,
);
const results = await mgr.search('关键 bug');
expect(results.length).toBeGreaterThan(0);
const newResult = results.find((r) => r.id === newId);
const oldResult = results.find((r) => r.id === oldId);
expect(newResult).toBeDefined();
expect(oldResult).toBeDefined();
expect(newResult!.score).toBeGreaterThan(oldResult!.score);
});
it('半衰期数学:30 天衰减系数恰为 0.5(score 相对无衰减×0.5', async () => {
// 新鲜记录(0 天)
const freshId = mgr.store({
type: 'episodic',
content: '衰减数学验证内容',
source: 'user_input',
importance: 0.7,
});
// 30 天记录
const agedId = mgr.store({
type: 'episodic',
content: '衰减数学验证内容',
source: 'user_input',
importance: 0.7,
});
db.prepare('UPDATE episodic_memories SET created_at = ? WHERE id = ?').run(
clock - 30 * 24 * 3600 * 1000,
agedId,
);
const results = await mgr.search('衰减数学验证内容');
const fresh = results.find((r) => r.id === freshId)!;
const aged = results.find((r) => r.id === agedId)!;
// score = cosine * decay * importanceFactor;两记录余弦与 importance 相同
// 故 aged.score / fresh.score ≈ 0.5(允许浮点误差)
expect(aged.score / fresh.score).toBeCloseTo(0.5, 1);
});
it('importance 权重:0.5 + importance*0.5 缩放(importance=1 得分为 0 的 2 倍)', async () => {
const lowId = mgr.store({
type: 'episodic',
content: '重要性权重验证',
source: 'user_input',
importance: 0,
});
const highId = mgr.store({
type: 'episodic',
content: '重要性权重验证',
source: 'user_input',
importance: 1,
});
// 同一时间创建,重要性不同 → factor = 0.5+0*0.5 vs 0.5+1*0.5
const results = await mgr.search('重要性权重验证');
const low = results.find((r) => r.id === lowId)!;
const high = results.find((r) => r.id === highId)!;
expect(high.score / low.score).toBeCloseTo(2.0, 1);
});
it('semantic 记忆可被检索(key+value 参与分词)', async () => {
mgr.store({
type: 'semantic',
content: '用户偏好深色主题',
source: 'imported',
importance: 0.5,
});
const results = await mgr.search('偏好');
expect(results.some((r) => r.type === 'semantic')).toBe(true);
});
it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5', async () => {
mgr.store({
type: 'working',
content: '当前任务文件',
sessionId: 's1',
importance: 0.5,
source: 'agent_thought',
});
const results = await mgr.search('当前任务');
expect(results.some((r) => r.type === 'working')).toBe(true);
});
it('type 过滤:仅返回指定类型', async () => {
mgr.store({
type: 'episodic',
content: 'typefilter 内容',
source: 'user_input',
importance: 0.7,
});
mgr.store({
type: 'semantic',
content: 'typefilter 内容',
source: 'imported',
importance: 0.5,
});
const episodic = await mgr.search('typefilter', { type: 'episodic' });
expect(episodic.every((r) => r.type === 'episodic')).toBe(true);
const semantic = await mgr.search('typefilter', { type: 'semantic' });
expect(semantic.every((r) => r.type === 'semantic')).toBe(true);
});
it('topK 限制返回条数', async () => {
for (let i = 0; i < 8; i++) {
mgr.store({
type: 'episodic',
content: `topk 内容 ${i}`,
source: 'user_input',
importance: 0.7,
});
}
const results = await mgr.search('topk 内容');
expect(results.length).toBeLessThanOrEqual(5); // 默认 topK=5
const results2 = await mgr.search('topk 内容', { topK: 2 });
expect(results2.length).toBeLessThanOrEqual(2);
});
it('minImportance 过滤低重要性记忆', async () => {
mgr.store({ type: 'episodic', content: '低重要内容', source: 'user_input', importance: 0.1 });
mgr.store({ type: 'episodic', content: '高重要内容', source: 'user_input', importance: 0.9 });
const results = await mgr.search('重要', { minImportance: 0.5 });
expect(results.every((r) => r.importance >= 0.5)).toBe(true);
});
it('score 字段为 finalScore = cosine * decay * (0.5+importance*0.5)>0 才返回)', async () => {
mgr.store({ type: 'episodic', content: 'score 数学', source: 'user_input', importance: 0.7 });
const results = await mgr.search('score 数学');
expect(results.length).toBeGreaterThan(0);
for (const r of results) {
expect(r.score).toBeGreaterThan(0);
expect(r.score).toBeLessThanOrEqual(1.5); // 理论最大值
}
});
});
describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () => {
let db: any;
let mgr: MemoryManager;
beforeEach(() => {
db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
});
afterEach(() => {
try {
db.close();
} catch {
/* ignore */
}
});
it('空查询与纯空白查询返回空数组', async () => {
expect(await mgr.search('')).toEqual([]);
expect(await mgr.search(' ')).toEqual([]);
expect(await mgr.search('', { topK: 3 })).toEqual([]);
});
it('无匹配关键词返回空数组(不抛错)', async () => {
mgr.store({ type: 'episodic', content: '存在的关键词', source: 'user_input', importance: 0.7 });
// "完全无关联" 的 bigram 与文档无重叠 → TF-IDF 0 命中;LIKE 也无子串 → []
expect(await mgr.search('完全无关联')).toEqual([]);
});
it('英文无命中时回退 LIKE 子串搜索', async () => {
mgr.store({
type: 'episodic',
content: 'hello world network',
source: 'user_input',
importance: 0.7,
});
// query "lo wo" 分词为 ['lo','wo'],与文档 token 无重叠 → TF-IDF 0 命中
// 但 "%lo wo%" 是 "hello world" 的连续子串 → LIKE 回退命中
const results = await mgr.search('lo wo');
expect(results.some((r) => r.content.includes('hello world'))).toBe(true);
});
it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', async () => {
mgr.store({
type: 'episodic',
content: '使用 50% 折扣 与 under_score',
source: 'user_input',
importance: 0.7,
});
mgr.store({
type: 'episodic',
content: '完全无关的内容',
source: 'user_input',
importance: 0.7,
});
// 查询 "%":分词为空 → 强制走 LIKE;若 % 未转义会匹配所有记录
const pct = await mgr.search('%');
expect(pct.some((r) => r.content.includes('50%'))).toBe(true);
expect(pct.some((r) => r.content === '完全无关的内容')).toBe(false);
// 查询 "_":若未转义会匹配任意单字符 → 误命中无关记录
const underscore = await mgr.search('_');
expect(underscore.some((r) => r.content === '完全无关的内容')).toBe(false);
});
it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', async () => {
mgr.store({
type: 'episodic',
content: '路径 C:\\Users\\test',
source: 'user_input',
importance: 0.7,
});
// 反斜杠单独作为查询 → tokenize 为空 → LIKE 路径;不转义会导致 SQLite 报错
await expect(mgr.search('\\')).resolves.toBeInstanceOf(Array);
});
it('search 的 topK 同时作用于回退路径', async () => {
for (let i = 0; i < 6; i++) {
mgr.store({
type: 'episodic',
content: `backup${i} 数据`,
source: 'user_input',
importance: 0.7,
});
}
const results = await mgr.search('backup', { topK: 3 });
expect(results.length).toBeLessThanOrEqual(3);
});
it('search 无结果时回退 LIKE 的 score = importance * timeDecay', async () => {
mgr.store({
type: 'episodic',
content: 'fallbackscore 内容',
source: 'user_input',
importance: 0.8,
});
// "allbackscor" 分词不在文档 token 中 → TF-IDF 0 命中;LIKE %allbackscor% 命中
const results = await mgr.search('allbackscor');
expect(results.length).toBeGreaterThan(0);
// 新记录 timeDecay≈1 → score≈importance=0.8
expect(results[0].score).toBeCloseTo(0.8, 1);
});
it('LIKE 回退:episodic 按 importance 降序返回', async () => {
mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.2 });
mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.9 });
const results = await mgr.search('排序验证');
expect(results[0].importance).toBe(0.9);
});
});
describe.skipIf(!dbAvailable)('MemoryManager — working memory CRUD', () => {
let db: any;
let mgr: MemoryManager;
beforeEach(() => {
db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
});
afterEach(() => {
try {
db.close();
} catch {
/* ignore */
}
});
it('setWorkingMemory 后可 getWorkingMemory 读回', () => {
mgr.setWorkingMemory('s1', 'task1', 'currentFile', '/src/a.ts');
const wm = mgr.getWorkingMemory('s1', 'task1');
expect(wm.get('currentFile')).toBe('/src/a.ts');
});
it('getWorkingMemory 不同 task 隔离', () => {
mgr.setWorkingMemory('s1', 'task1', 'k', 'v1');
mgr.setWorkingMemory('s1', 'task2', 'k', 'v2');
expect(mgr.getWorkingMemory('s1', 'task1').get('k')).toBe('v1');
expect(mgr.getWorkingMemory('s1', 'task2').get('k')).toBe('v2');
});
it('setWorkingMemory 同 key 覆盖(INSERT OR REPLACE', () => {
mgr.setWorkingMemory('s1', 'task1', 'k', 'old');
mgr.setWorkingMemory('s1', 'task1', 'k', 'new');
expect(mgr.getWorkingMemory('s1', 'task1').get('k')).toBe('new');
expect(mgr.getWorkingMemory('s1', 'task1').size).toBe(1);
});
it('getWorkingMemory 无记录返回空 Map', () => {
expect(mgr.getWorkingMemory('nobody')).toEqual(new Map());
});
it('clearWorkingMemory 按 session 清除全部 task', () => {
mgr.setWorkingMemory('s1', 'task1', 'a', '1');
mgr.setWorkingMemory('s1', 'task2', 'b', '2');
mgr.setWorkingMemory('s2', 'task1', 'c', '3');
mgr.clearWorkingMemory('s1');
expect(mgr.getWorkingMemory('s1', 'task1').size).toBe(0);
expect(mgr.getWorkingMemory('s1', 'task2').size).toBe(0);
// 其他会话不受影响
expect(mgr.getWorkingMemory('s2', 'task1').get('c')).toBe('3');
});
it('clearWorkingMemory 指定 taskId 只清该 task', () => {
mgr.setWorkingMemory('s1', 'task1', 'a', '1');
mgr.setWorkingMemory('s1', 'task2', 'b', '2');
mgr.clearWorkingMemory('s1', 'task1');
expect(mgr.getWorkingMemory('s1', 'task1').size).toBe(0);
expect(mgr.getWorkingMemory('s1', 'task2').get('b')).toBe('2');
});
it('clearWorkingMemory 对空会话不抛错', () => {
expect(() => mgr.clearWorkingMemory('ghost')).not.toThrow();
});
});
describe.skipIf(!dbAvailable)('MemoryManager — cleanupExpired', () => {
let db: any;
let mgr: MemoryManager;
let clock: number;
beforeEach(() => {
db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
clock = Date.now();
vi.spyOn(Date, 'now').mockImplementation(() => clock);
});
afterEach(() => {
vi.restoreAllMocks();
try {
db.close();
} catch {
/* ignore */
}
});
// v0.8.2 P3-6 注释修正:v0.8.1 P0-2 已让 store() 真实写入 expires_at(本组
// 早期版本注释"INSERT 不含 expires_at 列"已过时)。此处直接经 SQL 写入仅为
// 测试夹具便利(绕过 store 的哈希/分词管线),锁定 cleanupExpired 的删除契约。
const insertExpiring = (id: string, expiresAt: number, content = '带过期记忆'): void => {
db.prepare(
`INSERT INTO episodic_memories (id, content, source, importance, created_at, expires_at)
VALUES (?, ?, 'user_input', 0.5, ?, ?)`,
).run(id, content, clock, expiresAt);
};
it('删除已过期 episodic 记忆并返回删除条数', () => {
insertExpiring('expired', clock - 1000);
insertExpiring('live', clock + 1000);
const deleted = mgr.cleanupExpired();
expect(deleted).toBe(1);
expect(
db.prepare('SELECT id FROM episodic_memories WHERE id = ?').get('expired'),
).toBeUndefined();
expect(db.prepare('SELECT id FROM episodic_memories WHERE id = ?').get('live')).toBeDefined();
});
it('无 expires_at 的记忆永不过期(不受 cleanup 影响)', () => {
const id = mgr.store({
type: 'episodic',
content: '永久记忆',
source: 'user_input',
importance: 0.7,
});
expect(mgr.cleanupExpired()).toBe(0);
expect(db.prepare('SELECT id FROM episodic_memories WHERE id = ?').get(id)).toBeDefined();
});
it('expires_at 恰等于当前时间视为未过期(< 严格小于)', () => {
insertExpiring('boundary', clock);
expect(mgr.cleanupExpired()).toBe(0);
});
it('空表 cleanupExpired 返回 0', () => {
expect(mgr.cleanupExpired()).toBe(0);
});
it('semantic/working 不参与 cleanup(仅 episodic 有过期语义)', () => {
mgr.store({ type: 'semantic', content: '语义记忆', source: 'imported', importance: 0.5 });
mgr.store({
type: 'working',
content: '工作记忆',
sessionId: 's1',
importance: 0.5,
source: 'agent_thought',
});
expect(mgr.cleanupExpired()).toBe(0);
});
it('多条过期记忆一次性全部清理(返回删除总数)', () => {
insertExpiring('e1', clock - 10);
insertExpiring('e2', clock - 100);
insertExpiring('e3', clock - 1000);
insertExpiring('live', clock + 1);
expect(mgr.cleanupExpired()).toBe(3);
expect(db.prepare('SELECT COUNT(*) AS c FROM episodic_memories').get().c).toBe(1);
});
});
// ===== v0.8.1: P0-2 生命周期 + P1-1 向量混合检索 =====
describe.skipIf(!dbAvailable)('MemoryManager — v0.8.1 生命周期与混合检索', () => {
let db: any;
let mgr: MemoryManager;
beforeAll(() => {
if (!dbAvailable) return;
db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
mgr.initialize();
});
afterAll(() => {
if (db) db.close();
});
afterEach(() => {
db.exec('DELETE FROM episodic_memories');
db.exec('DELETE FROM semantic_memories');
db.exec('DELETE FROM working_memories');
mgr.setEmbedder(null);
});
it('P0-2: store 接受 expiresAt 并写入 episodic_memories.expires_at', () => {
const ttl = Date.now() + 1000;
mgr.store({
type: 'episodic',
content: 'TTL 验证内容',
source: 'tool_result',
importance: 0.6,
expiresAt: ttl,
});
const row = db
.prepare('SELECT expires_at FROM episodic_memories WHERE content = ?')
.get('TTL 验证内容') as {
expires_at: number | null;
};
expect(row.expires_at).toBe(ttl);
});
it('P0-2: semantic 检索命中后 access_count 递增', async () => {
mgr.store({
type: 'semantic',
content: '用户偏好简洁回答',
summary: 'pref-brief',
source: 'agent_thought',
importance: 0.9,
});
const before = (
db.prepare('SELECT access_count FROM semantic_memories WHERE key = ?').get('pref-brief') as {
access_count: number;
}
).access_count;
await mgr.search('偏好简洁');
const after = (
db.prepare('SELECT access_count FROM semantic_memories WHERE key = ?').get('pref-brief') as {
access_count: number;
}
).access_count;
expect(after).toBeGreaterThan(before);
});
it('P1-1: 注入 embedder 后混合检索命中同义改写(TF-IDF 单路召回不到的查询)', async () => {
// 文档:"回复要短" — 查询"我喜欢简洁回答"(同义改写,无字面重叠)
mgr.store({
type: 'semantic',
content: '回复要短',
summary: 'style-rule',
source: 'agent_thought',
importance: 0.9,
});
// 词表不重叠 → TF-IDF 嵌入向量正交 → 纯 TF-IDF 0 分
const tfidfOnly = await mgr.search('我喜欢简洁回答');
expect(tfidfOnly).toHaveLength(0);
// 注入固定向量的 embedder:同义改写在向量空间中余弦 > 0
const VECTORS: Record<string, number[]> = {
'回复要短 style-rule': [1, 0.9, 0],
我喜欢简洁回答: [0.95, 1, 0.1],
无关内容xyz: [0, 0.1, 1],
};
mgr.setEmbedder({
embed: async (text) => {
for (const [k, v] of Object.entries(VECTORS)) {
if (text.includes(k)) return v;
}
return [0, 0, 1];
},
});
// 首次检索触发存量记忆的惰性向量回填(本轮仍走 TF-IDF → 0 命中)
await mgr.search('我喜欢简洁回答');
await new Promise((r) => setTimeout(r, 10));
// 回填完成后,向量路径生效 → 同义改写命中
const hybrid = await mgr.search('我喜欢简洁回答');
expect(hybrid.length).toBeGreaterThan(0);
expect(hybrid[0].content).toBe('回复要短');
expect(hybrid[0].score).toBeGreaterThan(0);
});
it('P1-1: embedder 抛错/返回 null → 回退纯 TF-IDF(行为兼容)', async () => {
mgr.store({
type: 'semantic',
content: 'TF-IDF 兜底验证',
summary: 'fallback-vec',
source: 'agent_thought',
importance: 0.9,
});
mgr.setEmbedder({
embed: async () => {
throw new Error('embed down');
},
});
const results = await mgr.search('TF-IDF 兜底验证');
expect(results.length).toBeGreaterThan(0);
mgr.setEmbedder({ embed: async () => null });
const results2 = await mgr.search('TF-IDF 兜底验证');
expect(results2.length).toBeGreaterThan(0);
});
it('P1-1: store 写入异步回填 embedding BLOB', async () => {
mgr.setEmbedder({ embed: async () => [0.5, 0.5, 0.5] });
mgr.store({
type: 'semantic',
content: '向量化回填验证',
summary: 'vec-backfill',
source: 'agent_thought',
importance: 0.8,
});
await new Promise((r) => setTimeout(r, 10));
const row = db
.prepare('SELECT embedding FROM semantic_memories WHERE key = ?')
.get('vec-backfill') as { embedding: Buffer | null };
expect(row.embedding).not.toBeNull();
expect(row.embedding!.length % 4).toBe(0);
});
});
// ===== v0.8.2 P3-1: access_count 保留 + 模型指纹 =====
describe.skipIf(!dbAvailable)('MemoryManager — v0.8.2 P3-1', () => {
let db: InstanceType<typeof Database>;
let mgr: MemoryManager;
beforeEach(() => {
db = new Database(':memory:');
createMemorySchema(db);
mgr = new MemoryManager(() => db);
});
it('semantic 同内容重复 store → ON CONFLICT upsertaccess_count 不归零', () => {
mgr.store({
type: 'semantic',
content: '用户偏好深色主题',
source: 'user_input',
importance: 0.9,
});
db.prepare('UPDATE semantic_memories SET access_count = 7').run();
// 同内容重复写入(key = contentHash,冲突命中同一行)
mgr.store({
type: 'semantic',
content: '用户偏好深色主题',
source: 'user_input',
importance: 0.9,
});
const rows = db.prepare('SELECT access_count, value FROM semantic_memories').all() as Array<{
access_count: number;
value: string;
}>;
// 旧 REPLACE 语义会删除重插 → 2 行且 access_count 归零
expect(rows).toHaveLength(1);
expect(rows[0].access_count).toBe(7);
});
it('embedding_model 指纹:模型不匹配的向量按缺失处理并触发重算', async () => {
mgr.store({
type: 'semantic',
content: '指纹校验内容',
summary: 'fp-check',
source: 'agent_thought',
importance: 0.8,
});
await new Promise((r) => setTimeout(r, 10));
// 模拟"用户更换了 embedding 模型":旧向量标记为旧模型
db.prepare(
"UPDATE semantic_memories SET embedding_model = 'old-model' WHERE key = 'fp-check'",
).run();
let reembedCalls = 0;
mgr.setEmbedder({
modelName: 'new-model',
embed: async () => {
reembedCalls += 1;
return [0.1, 0.2, 0.3];
},
});
const results = await mgr.search('指纹校验内容');
// 检索本身可用(回退 TF-IDF);旧向量被排队用新模型重算
expect(results.length).toBeGreaterThan(0);
await new Promise((r) => setTimeout(r, 10));
expect(reembedCalls).toBeGreaterThanOrEqual(1);
const row = db
.prepare("SELECT embedding, embedding_model FROM semantic_memories WHERE key = 'fp-check'")
.get() as { embedding: Buffer | null; embedding_model: string | null };
expect(row.embedding_model).toBe('new-model');
expect(row.embedding).not.toBeNull();
});
});