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 跳过
847 lines
28 KiB
TypeScript
847 lines
28 KiB
TypeScript
/**
|
||
* 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);
|
||
// 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_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);
|
||
});
|
||
});
|