Files
metona-ai-desktop/electron/harness/memory/__tests__/memory-manager.test.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

847 lines
28 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 列)
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
);
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
);
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 重置)', () => {
// v0.7.4 强化断言: 若 IDF 缓存未失效/检索不扫描新行,store 后 search 返回空即失败。
mgr.store({ type: 'episodic', content: 'hello world', source: 'user_input', importance: 0.7 });
mgr.search('hello'); // 建立 IDF 缓存
// 再 store 一条 → 缓存应失效,新内容可被检索
mgr.store({
type: 'episodic',
content: 'another content',
source: 'user_input',
importance: 0.7,
});
const results = mgr.search('another');
expect(results.some((r) => r.content === 'another content')).toBe(true);
// 双向验证:缓存重建后旧内容仍可检索(不因重建丢失)
const oldResults = 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('相同关键词:得分高者排前(内容重复度越高得分越高)', () => {
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 = 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 天半衰期)', () => {
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 = 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', () => {
// 新鲜记录(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 = 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 倍)', () => {
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 = 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 参与分词)', () => {
mgr.store({
type: 'semantic',
content: '用户偏好深色主题',
source: 'imported',
importance: 0.5,
});
const results = mgr.search('偏好');
expect(results.some((r) => r.type === 'semantic')).toBe(true);
});
it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5', () => {
mgr.store({
type: 'working',
content: '当前任务文件',
sessionId: 's1',
importance: 0.5,
source: 'agent_thought',
});
const results = mgr.search('当前任务');
expect(results.some((r) => r.type === 'working')).toBe(true);
});
it('type 过滤:仅返回指定类型', () => {
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 = mgr.search('typefilter', { type: 'episodic' });
expect(episodic.every((r) => r.type === 'episodic')).toBe(true);
const semantic = mgr.search('typefilter', { type: 'semantic' });
expect(semantic.every((r) => r.type === 'semantic')).toBe(true);
});
it('topK 限制返回条数', () => {
for (let i = 0; i < 8; i++) {
mgr.store({
type: 'episodic',
content: `topk 内容 ${i}`,
source: 'user_input',
importance: 0.7,
});
}
const results = mgr.search('topk 内容');
expect(results.length).toBeLessThanOrEqual(5); // 默认 topK=5
const results2 = mgr.search('topk 内容', { topK: 2 });
expect(results2.length).toBeLessThanOrEqual(2);
});
it('minImportance 过滤低重要性记忆', () => {
mgr.store({ type: 'episodic', content: '低重要内容', source: 'user_input', importance: 0.1 });
mgr.store({ type: 'episodic', content: '高重要内容', source: 'user_input', importance: 0.9 });
const results = 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 才返回)', () => {
mgr.store({ type: 'episodic', content: 'score 数学', source: 'user_input', importance: 0.7 });
const results = 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('空查询与纯空白查询返回空数组', () => {
expect(mgr.search('')).toEqual([]);
expect(mgr.search(' ')).toEqual([]);
expect(mgr.search('', { topK: 3 })).toEqual([]);
});
it('无匹配关键词返回空数组(不抛错)', () => {
mgr.store({ type: 'episodic', content: '存在的关键词', source: 'user_input', importance: 0.7 });
// "完全无关联" 的 bigram 与文档无重叠 → TF-IDF 0 命中;LIKE 也无子串 → []
expect(mgr.search('完全无关联')).toEqual([]);
});
it('英文无命中时回退 LIKE 子串搜索', () => {
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 = mgr.search('lo wo');
expect(results.some((r) => r.content.includes('hello world'))).toBe(true);
});
it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', () => {
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 = mgr.search('%');
expect(pct.some((r) => r.content.includes('50%'))).toBe(true);
expect(pct.some((r) => r.content === '完全无关的内容')).toBe(false);
// 查询 "_":若未转义会匹配任意单字符 → 误命中无关记录
const underscore = mgr.search('_');
expect(underscore.some((r) => r.content === '完全无关的内容')).toBe(false);
});
it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', () => {
mgr.store({
type: 'episodic',
content: '路径 C:\\Users\\test',
source: 'user_input',
importance: 0.7,
});
// 反斜杠单独作为查询 → tokenize 为空 → LIKE 路径;不转义会导致 SQLite 报错
expect(() => mgr.search('\\')).not.toThrow();
});
it('search 的 topK 同时作用于回退路径', () => {
for (let i = 0; i < 6; i++) {
mgr.store({
type: 'episodic',
content: `backup${i} 数据`,
source: 'user_input',
importance: 0.7,
});
}
const results = mgr.search('backup', { topK: 3 });
expect(results.length).toBeLessThanOrEqual(3);
});
it('search 无结果时回退 LIKE 的 score = importance * timeDecay', () => {
mgr.store({
type: 'episodic',
content: 'fallbackscore 内容',
source: 'user_input',
importance: 0.8,
});
// "allbackscor" 分词不在文档 token 中 → TF-IDF 0 命中;LIKE %allbackscor% 命中
const results = 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 降序返回', () => {
mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.2 });
mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.9 });
const results = 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 */
}
});
// 注意:manager.store() 的 episodic INSERT 不含 expires_at 列(源码已知缺口,
// main.ts 注释亦确认"expires_at 无写入方")—— 本组用例直接经 SQL 写入
// expires_at 模拟真实过期行,锁定 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);
});
});