feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

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:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
@@ -0,0 +1,844 @@
/**
* 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);
});
});
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);
});
});