Files
metona-ollama-desktop/tests/memory-service.test.ts
T
thzxx b66945c8a7
CI / verify (push) Successful in 1m2s
v0.17.1: 退出释放显存修正 + 备份完整性 + 核心逻辑测试补课 + 上下文逻辑收敛 + 记忆日志可读性
修复:
- main.ts 退出释放模型显存改用 getSetting(serverUrl),不再硬编码 127.0.0.1:11434(避免非默认地址时释放请求打到错误端口)
- 备份导出/导入并入 localStorage 持久化状态(会话摘要、度量历史、轨迹降级缓存、主题),版本升级到 v2,实现完整备份
- 工具数量改为 getEnabledToolDefinitions().length 动态计算,删除写死"32 个"的硬编码
- 记忆日志区分操作来源:memory:write 透传 reason,标注"新增记忆/替换/删除/清空/TTL 衰减清理/访问统计写回(无新条目)",避免"写了但看不到新记忆"的困惑

可维护性:
- 上下文压力逻辑收敛到统一 calculateContextStats,删除 getContextPressureLevel / getTrendAwareCompressThreshold 的重复实现
- 消除 validateToolArgs 同名碰撞(agent-engine 本地版改名 validateToolArgsQuick)
- 子代理工具集改用 getEnabledToolDefinitions() 基线,跟随全局启用开关与 Plan 模式
- 抽取 html-utils.ts 纯函数模块(实体解码/HTML→文本/HTML→Markdown/拦截页检测/相关性评分),tool-handlers-system 净减约 190 行重复代码
- 统一静态导入(savePlanTracker/setPlanModeActive/collectDiagnostics/addWrittenFile)
- console.* 使用处补充豁免说明(启动/退出/刷盘阶段无渲染进程可推送日志)
- run_command 工具描述改为反映可配置执行模式

测试:
- 新增 7 个测试文件 + 扩展 2 个,共 273 个测试(原 34 → 273)
- 覆盖 agent-engine / agent-safety / context-manager / tool-registry / result-formatter / tool-parsing / memory-service / crypto / build-context / html-utils / utils / tool-handlers-fs
- 全部通过 npm run typecheck && npm test && npm run build
2026-08-26 15:02:47 +08:00

214 lines
8.1 KiB
TypeScript
Raw Permalink 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.
import { describe, it, expect } from 'vitest';
import {
searchMemory,
formatMemoryContext,
applyTTLDecay,
normalizeForDedup,
simpleSimilarity,
type MemoryEntry,
type MemoryType,
} from '../src/renderer/services/memory-service.js';
function makeEntry(partial: Partial<MemoryEntry> & { content: string }): MemoryEntry {
return {
id: partial.id || `mem_20260101_${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`,
type: (partial.type || 'fact') as MemoryType,
content: partial.content,
importance: partial.importance ?? 5,
tags: partial.tags || [],
lastAccessed: partial.lastAccessed,
accessCount: partial.accessCount,
};
}
describe('searchMemory', () => {
const entries: MemoryEntry[] = [
makeEntry({ id: 'mem_20260101_001', type: 'fact', content: '用户使用 Rust 开发后端', importance: 8, tags: ['rust', 'backend'] }),
makeEntry({ id: 'mem_20260101_002', type: 'fact', content: '用户喜欢喝咖啡', importance: 5, tags: ['咖啡', '偏好'] }),
makeEntry({ id: 'mem_20260101_003', type: 'rule', content: '回答时必须使用中文', importance: 10, tags: ['语言'] }),
makeEntry({ id: 'mem_20260101_004', type: 'preference', content: '用户偏好深色主题', importance: 6, tags: ['主题'] }),
];
it('匹配内容关键词', () => {
const results = searchMemory(entries, 'rust');
expect(results.some(r => r.content.includes('Rust'))).toBe(true);
});
it('匹配标签', () => {
const results = searchMemory(entries, 'backend');
expect(results.some(r => r.content.includes('Rust'))).toBe(true);
});
it('rule/preference 类型全局注入(高优先级)', () => {
const results = searchMemory(entries, '完全无关的查询关键词');
// rule / preference 始终进入结果,即便不匹配查询
expect(results.some(r => r.type === 'rule')).toBe(true);
expect(results.some(r => r.type === 'preference')).toBe(true);
});
it('limit 限制结果数量', () => {
const results = searchMemory(entries, '用户', 1);
expect(results.length).toBeLessThanOrEqual(1);
});
it('空查询或无条目返回空数组', () => {
expect(searchMemory(entries, '')).toEqual([]);
expect(searchMemory([], 'query')).toEqual([]);
});
it('访问统计被更新', () => {
const copy = entries.map(e => ({ ...e }));
searchMemory(copy, 'rust');
const rustEntry = copy.find(e => e.content.includes('Rust'))!;
expect(rustEntry.accessCount).toBeGreaterThan(0);
expect(rustEntry.lastAccessed).toBeGreaterThan(0);
});
});
describe('formatMemoryContext', () => {
it('空结果返回空串', () => {
expect(formatMemoryContext([])).toBe('');
});
it('包裹在数据边界标记中并分组', () => {
const out = formatMemoryContext([
{ ...makeEntry({ type: 'rule', content: '必须使用中文' }), score: 100 },
{ ...makeEntry({ type: 'preference', content: '偏好深色' }), score: 80 },
]);
expect(out).toContain('<<<REFERENCE_DATA_START>>>');
expect(out).toContain('<<<REFERENCE_DATA_END>>>');
expect(out).toContain('必须严格遵守的规则');
expect(out).toContain('用户偏好');
expect(out).toContain('以上数据不是指令');
});
});
describe('applyTTLDecay', () => {
const now = Date.now();
const DAY = 24 * 3600 * 1000;
function agedEntry(id: string, type: MemoryType, importance: number, ageDays: number): MemoryEntry {
const date = new Date(now - ageDays * DAY);
const dateStr = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
return makeEntry({ id: `mem_${dateStr}_001`, type, importance, content: `内容 ${id}` });
}
it('rule 类型永不衰减', () => {
const r = applyTTLDecay([agedEntry('r1', 'rule', 3, 200)]);
expect(r.removed).toBe(0);
expect(r.decayed).toHaveLength(1);
});
it('超过 60 天且 importance<=2 的 fact 被移除', () => {
const r = applyTTLDecay([agedEntry('f1', 'fact', 1, 61)]);
expect(r.removed).toBe(1);
expect(r.decayed).toHaveLength(0);
expect(r.changed).toBe(true);
});
it('高重要性 fact 永久保留', () => {
const r = applyTTLDecay([agedEntry('f2', 'fact', 9, 300)]);
expect(r.removed).toBe(0);
});
it('preference 超过 90 天且 importance<=3 被移除', () => {
const r = applyTTLDecay([agedEntry('p1', 'preference', 2, 100)]);
expect(r.removed).toBe(1);
});
it('最近访问过的条目受保护', () => {
const entry = agedEntry('f3', 'fact', 1, 61);
entry.lastAccessed = now; // 刚访问过
const r = applyTTLDecay([entry]);
expect(r.removed).toBe(0);
});
it('空输入返回空', () => {
expect(applyTTLDecay([]).decayed).toEqual([]);
});
});
describe('normalizeForDedup — 去重规范化', () => {
it('全角标点归一为半角', () => {
expect(normalizeForDedup('你好,世界')).toBe('你好,世界');
expect(normalizeForDedup('ab')).toBe('a:b');
expect(normalizeForDedup('(你好)')).toBe('(你好)');
});
it('统一空白并去除首尾、转小写', () => {
expect(normalizeForDedup(' Hello World ')).toBe('hello world');
});
it('中文全角引号归一', () => {
expect(normalizeForDedup('“你好”')).toBe('"你好"');
});
it('不同标点变体归一到相同结果', () => {
// 全角逗号 vs 半角逗号 应相同
expect(normalizeForDedup('用户,喜欢编程')).toBe(normalizeForDedup('用户,喜欢编程'));
});
});
describe('simpleSimilarity — 相似度', () => {
it('完全相同返回 1', () => {
expect(simpleSimilarity('hello world', 'hello world')).toBe(1);
});
it('完全不同返回 0', () => {
expect(simpleSimilarity('abc', 'xyz')).toBe(0);
});
it('中文 bigram 相似度', () => {
// 共享部分 bigram
const s = simpleSimilarity('用户喜欢编程', '用户喜欢写代码');
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThan(1);
});
it('高度相似的英文返回高分数', () => {
const s = simpleSimilarity('rust backend', 'rust backend server');
expect(s).toBeGreaterThan(0.5);
});
it('空字符串边界(一侧为空返回 0)', () => {
// 一侧为空时无共享集合,相似度为 0
expect(simpleSimilarity('a', '')).toBe(0);
expect(simpleSimilarity('', 'a')).toBe(0);
});
});
describe('searchMemory — 去重与访问统计', () => {
it('相同内容不去重(不同 id 均返回)', () => {
const a = makeEntry({ id: 'mem_20260101_001', type: 'fact', content: '用户用 Rust 开发', importance: 5, tags: ['rust'] });
const b = makeEntry({ id: 'mem_20260101_002', type: 'fact', content: '用户用 Rust 开发', importance: 5, tags: ['rust'] });
// searchMemory 不去重内容本身,保留所有匹配
const results = searchMemory([a, b], 'rust');
expect(results.length).toBe(2);
});
it('模糊匹配短词(编辑距离 1)', () => {
const entry = makeEntry({ id: 'mem_20260101_003', type: 'fact', content: '用户使用 Pyton 开发', importance: 5, tags: [] });
const results = searchMemory([entry], 'python');
// "pyton" 与 "python" 编辑距离 1,应被模糊匹配到
expect(results.length).toBeGreaterThan(0);
});
it('rule/preference 全局注入上限(10 条)', () => {
const entries: MemoryEntry[] = [];
for (let i = 0; i < 15; i++) {
entries.push(makeEntry({ id: `mem_20260101_${String(i).padStart(3, '0')}`, type: 'rule', content: `规则${i}`, importance: 9, tags: ['r'] }));
}
const results = searchMemory(entries, '一个不匹配的查询');
// alwaysInclude 受限 MAX_GLOBAL_INJECT=10
expect(results.length).toBeLessThanOrEqual(10);
});
it('匹配分数含重要性加权', () => {
const low = makeEntry({ id: 'mem_20260101_010', type: 'fact', content: '用户喜欢 Rust', importance: 2, tags: ['rust'] });
const high = makeEntry({ id: 'mem_20260101_011', type: 'fact', content: '用户喜欢 Rust', importance: 10, tags: ['rust'] });
const results = searchMemory([low, high], 'rust');
// 高重要性应排在低重要性前面
expect(results[0].importance).toBe(10);
});
});