v0.17.1: 退出释放显存修正 + 备份完整性 + 核心逻辑测试补课 + 上下文逻辑收敛 + 记忆日志可读性
CI / verify (push) Successful in 1m2s

修复:
- 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
This commit is contained in:
2026-08-26 15:02:47 +08:00
parent 0b172d30c0
commit b66945c8a7
32 changed files with 2082 additions and 326 deletions
+213
View File
@@ -0,0 +1,213 @@
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);
});
});