/** * MemoryMaintainer 测试(v0.8.1 P1-2 MEMORY.md 维护闭环) * * 锁定契约: * 1. parseMemoryEntries / buildMemoryEntriesDigest —— 分区条目摘要(纯条目行, * 消除 Consolidator 旧全文截断的去重盲区) * 2. apply 的精确匹配防线 —— LLM 建议的 entry 必须原样存在,防幻觉改写无关内容 * 3. delete/update 动作重写 MEMORY.md + 同步 semantic_memories 双轨一致 */ import { describe, it, expect, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { MemoryMaintainer, parseMemoryEntries, buildMemoryEntriesDigest } from '../maintainer'; import type { MemoryMaintenanceAction } from '../maintainer'; const SAMPLE = `# MEMORY.md — AI 持久记忆 > 最后更新: 2026-09-07 ## 用户偏好 - [沟通风格] 用户喜欢简洁的回答 - [工具偏好] 项目使用 pnpm ## 项目上下文 - [Metona] 技术栈: Electron + React ## 待办事项 - [done] 旧待办已完成 `; describe('parseMemoryEntries / buildMemoryEntriesDigest(v0.8.1 P1-2)', () => { it('解析分区与条目(跳过元数据头)', () => { const sections = parseMemoryEntries(SAMPLE); expect(sections).toHaveLength(3); expect(sections[0].section).toBe('用户偏好'); expect(sections[0].entries).toEqual([ '[沟通风格] 用户喜欢简洁的回答', '[工具偏好] 项目使用 pnpm', ]); }); it('digest 为纯条目行形态且条目全文可见(无 3000 字符截断盲区)', () => { const digest = buildMemoryEntriesDigest(parseMemoryEntries(SAMPLE)); expect(digest).toContain('## 用户偏好'); expect(digest).toContain('- [沟通风格] 用户喜欢简洁的回答'); // 旧全文形态的头部元数据不进入 digest expect(digest).not.toContain('最后更新'); // 超过旧 3000 字符预算的记忆尾部条目同样完整进入 digest const manyEntries = Array.from({ length: 200 }, (_, i) => `- 条目 ${i} ${'x'.repeat(20)}`); const bigMemory = `## 项目上下文\n${manyEntries.join('\n')}`; const bigDigest = buildMemoryEntriesDigest(parseMemoryEntries(bigMemory)); expect(bigDigest).toContain('条目 199'); }); }); describe('MemoryMaintainer.apply — 精确匹配与双轨同步', () => { function makeMaintainer(memory: string): { maintainer: MemoryMaintainer; getMemory: () => string; db: { prepare(sql: string): { run(...args: unknown[]): { changes: number } } }; } { let current = memory; const semanticRows: Array<{ content: string }> = [{ content: '[沟通风格] 用户喜欢简洁的回答' }]; const db = { prepare: (sql: string) => ({ run: (...args: unknown[]) => { if (sql.startsWith('DELETE')) { const before = semanticRows.length; const target = semanticRows.find((r) => r.content === args[0]); if (target) semanticRows.splice(semanticRows.indexOf(target), 1); return { changes: before - semanticRows.length }; } if (sql.startsWith('UPDATE')) { const row = semanticRows.find((r) => r.content === args[2]); if (row) { row.content = args[0] as string; return { changes: 1 }; } return { changes: 0 }; } return { changes: 0 }; }, }), }; const maintainer = new MemoryMaintainer( () => { throw new Error('not used in apply'); }, { getFiles: () => ({ soul: '', memory: current }), rewriteMemory: (content: string) => { current = content; }, } as never, () => db as never, ); return { maintainer, getMemory: () => current, db }; } it('delete 精确命中 → 行被移除;未命中条目被跳过(防幻觉改写)', () => { const { maintainer, getMemory } = makeMaintainer(SAMPLE); const actions: MemoryMaintenanceAction[] = [ { action: 'delete', section: '待办事项', entry: '[done] 旧待办已完成' }, // 幻觉条目:文件中不存在 → 必须跳过 { action: 'delete', section: '用户偏好', entry: '不存在的条目' }, ]; const result = maintainer.apply(actions); expect(result.applied).toBe(1); expect(result.skipped).toBe(1); const after = getMemory(); expect(after).not.toContain('[done] 旧待办已完成'); expect(after).toContain('用户喜欢简洁的回答'); expect(after).toContain('## 待办事项'); // 分区头保留(空分区仍保留结构) }); it('update(合并)→ 替换条目并同步 semantic_memories', () => { const { maintainer, getMemory, db } = makeMaintainer(SAMPLE); const actions: MemoryMaintenanceAction[] = [ { action: 'update', section: '用户偏好', entry: '[沟通风格] 用户喜欢简洁的回答', newEntry: '[沟通风格] 用户喜欢简洁的回答,不需要过度解释', }, ]; const result = maintainer.apply(actions); expect(result.applied).toBe(1); expect(getMemory()).toContain('不需要过度解释'); const row = db .prepare('SELECT * FROM semantic_memories WHERE content = ?') .run('[沟通风格] 用户喜欢简洁的回答,不需要过度解释'); expect(row).toBeDefined(); }); it('动作数上限 30(防 LLM 过度建议)', () => { const { maintainer } = makeMaintainer(SAMPLE); const actions: MemoryMaintenanceAction[] = Array.from({ length: 40 }, () => ({ action: 'delete' as const, section: '待办事项', entry: '不存在的条目', })); const result = maintainer.apply(actions); expect(result.skipped).toBe(40); expect(result.applied).toBe(0); }); }); describe('MemoryMaintainer.analyze — sectionEntryCounts(v0.8.1 review O2)', () => { function makeAnalyzer( memory: string, llmReply: string, ): { maintainer: MemoryMaintainer; } { const adapter = { send: vi.fn().mockResolvedValue({ content: llmReply }), } as never; return { maintainer: new MemoryMaintainer( () => adapter, { getFiles: () => ({ soul: '', memory }), rewriteMemory: () => {}, } as never, (() => ({})) as never, ), }; } it('proposal 携带各分区条目数(空分区提示的数据源)', async () => { const { maintainer } = makeAnalyzer( SAMPLE, '[{"action":"delete","section":"待办事项","entry":"[done] 旧待办已完成","reason":"已完成"}]', ); const proposal = await maintainer.analyze(); expect(proposal.sectionEntryCounts['待办事项']).toBe(1); expect(proposal.sectionEntryCounts['用户偏好']).toBe(2); }); });