feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
@@ -0,0 +1,844 @@
|
||||
/**
|
||||
* MemoryManager 测试(v0.2.0 TF-IDF 检索 —— 此前零测试)
|
||||
*
|
||||
* 锁定记忆管理器核心契约:
|
||||
* 1. tokenize:英文词/CJK bigram/单字 CJK 子句/跨标点不组合/小写归一
|
||||
* 2. tfidfSearch:余弦打分排序/时间衰减(30 天半衰期)/重要性权重/type 过滤/topK
|
||||
* 3. search:TF-IDF 无命中回退 LIKE/ESCAPE 转义/topK/threshold/sessionId 无效
|
||||
* 4. store:三层记忆/importance 默认计算/semantic contentHash 去重/working 覆盖/tf_cache
|
||||
* 5. working memory CRUD:get/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('跨标点边界不产生噪声 bigram(v0.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);
|
||||
});
|
||||
});
|
||||
|
||||
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_cache(JSON 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 store:category 固定为 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user