/** * MemoryConsolidator 测试(v0.7.2 覆盖补齐 —— 此前零测试) * * 锁定会话结束记忆固化的核心契约: * 1. LLM 提取 JSON → 白名单 section 过滤 → MEMORY.md 追加 + semantic_memories 双轨写入 * 2. 边界:markdown 代码围栏剥离 / section 白名单 / 单次最多 5 条 / 单条 500 截断 * 3. 失败语义:LLM 抛错 / 非 JSON / 空响应 → 静默降级,不冒泡 * 4. 退出等待:isRunning / waitForCompletion(v0.3.18 修复的回归防线) * 5. section → importance 分级映射 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { MemoryConsolidator } from '../consolidator'; import type { IMetonaProviderAdapter, MetonaRequest } from '../../types'; import type { WorkspaceService } from '../../../services/workspace.service'; import type { MemoryManager } from '../manager'; function makeAdapter(responseContent: string | Error): { adapter: IMetonaProviderAdapter; send: ReturnType; } { const send = vi.fn(async (): Promise<{ content: string }> => { if (responseContent instanceof Error) throw responseContent; return { content: responseContent }; }); return { adapter: { providerId: 'mock', supportedModels: [], supportsToolCalling: true, supportsThinking: false, getContextWindow: () => 1_000_000, send: send as unknown as IMetonaProviderAdapter['send'], sendStream: vi.fn(), } as unknown as IMetonaProviderAdapter, send, }; } function makeDeps(memory?: string): { workspace: WorkspaceService; appendMemory: ReturnType; manager: MemoryManager; store: ReturnType; } { const appendMemory = vi.fn(); const store = vi.fn(); return { workspace: { getFiles: vi.fn(() => ({ soul: '', memory: memory ?? '' })), appendMemory, } as unknown as WorkspaceService, appendMemory, manager: { store } as unknown as MemoryManager, store, }; } beforeEach(() => { vi.clearAllMocks(); }); describe('MemoryConsolidator — 正常固化路径', () => { it('提取结果追加到 MEMORY.md + 双轨写入 semantic_memories', async () => { const { adapter } = makeAdapter( JSON.stringify([{ section: '用户偏好', entry: '偏好深色主题' }]), ); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); consolidator.setMemoryManager(deps.manager); const result = await consolidator.consolidate('用户消息', '最终回答', []); expect(result.appended).toBe(1); expect(deps.appendMemory).toHaveBeenCalledWith('用户偏好', '偏好深色主题'); // 双轨:importance 按 section 分级(用户偏好 → 0.9) expect(deps.store).toHaveBeenCalledWith( expect.objectContaining({ type: 'semantic', importance: 0.9, content: '偏好深色主题' }), ); }); it('markdown 代码围栏包裹的 JSON 正常剥离解析', async () => { const { adapter } = makeAdapter('```json\n[{"section":"重要决策","entry":"采用 SQLite"}]\n```'); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); consolidator.setMemoryManager(deps.manager); const result = await consolidator.consolidate('q', 'a', []); expect(result.appended).toBe(1); expect(deps.appendMemory).toHaveBeenCalledWith('重要决策', '采用 SQLite'); }); it('section → importance 分级:重要决策 0.9 / 项目上下文 0.7 / 待办事项 0.5', async () => { const { adapter } = makeAdapter( JSON.stringify([ { section: '项目上下文', entry: 'b' }, { section: '待办事项', entry: 'c' }, ]), ); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); consolidator.setMemoryManager(deps.manager); await consolidator.consolidate('q', 'a', []); const importances = deps.store.mock.calls.map( (c: unknown[]) => (c[0] as { importance: number }).importance, ); expect(importances).toEqual([0.7, 0.5]); }); it('固化请求携带 30s 超时保护与 maxTokens 1024', async () => { const { adapter, send } = makeAdapter(JSON.stringify([])); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); await consolidator.consolidate('q', 'a', []); const req = send.mock.calls[0][0] as MetonaRequest; expect(req.params.maxTokens).toBe(1024); expect(req.params.stream).toBe(false); expect(req.params.thinkingEnabled).toBe(false); }); }); describe('MemoryConsolidator — 边界与安全过滤', () => { it('非白名单 section 被跳过(skipped 计数)', async () => { const { adapter } = makeAdapter( JSON.stringify([ { section: '用户偏好', entry: 'ok' }, { section: '自由发挥', entry: '不合法' }, ]), ); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); const result = await consolidator.consolidate('q', 'a', []); expect(result.appended).toBe(1); expect(result.skipped).toBe(1); expect(deps.appendMemory).toHaveBeenCalledTimes(1); }); it('单次固化最多追加 5 条(MAX_ENTRIES_PER_CONSOLIDATION)', async () => { const entries = Array.from({ length: 8 }, (_, i) => ({ section: '用户偏好', entry: `entry-${i}`, })); const { adapter } = makeAdapter(JSON.stringify(entries)); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); const result = await consolidator.consolidate('q', 'a', []); expect(result.appended).toBe(5); expect(deps.appendMemory).toHaveBeenCalledTimes(5); }); it('超长条目截断到 500 字符', async () => { const longEntry = 'x'.repeat(1200); const { adapter } = makeAdapter(JSON.stringify([{ section: '用户偏好', entry: longEntry }])); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); const result = await consolidator.consolidate('q', 'a', []); expect(result.appended).toBe(1); const appended = deps.appendMemory.mock.calls[0][1] as string; expect(appended).toHaveLength(500); }); it('LLM 返回空数组 → appended 0', async () => { const { adapter } = makeAdapter('[]'); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); const result = await consolidator.consolidate('q', 'a', []); expect(result.appended).toBe(0); expect(deps.appendMemory).not.toHaveBeenCalled(); }); it('LLM 返回非 JSON(如纯文本)→ 静默降级 appended 0,不抛错', async () => { const { adapter } = makeAdapter('我觉得这段对话没什么值得记住的。'); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); await expect(consolidator.consolidate('q', 'a', [])).resolves.toMatchObject({ appended: 0 }); }); it('LLM 抛错(超时/网络)→ 静默降级,不冒泡', async () => { const { adapter } = makeAdapter(new Error('summary timeout')); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); await expect(consolidator.consolidate('q', 'a', [])).resolves.toMatchObject({ appended: 0, entries: [], skipped: 0, }); }); it('appendMemory 单条失败 → 计入 skipped,其余条目继续', async () => { const { adapter } = makeAdapter( JSON.stringify([ { section: '用户偏好', entry: 'first' }, { section: '重要决策', entry: 'second' }, ]), ); const deps = makeDeps(); deps.appendMemory.mockImplementation((section: string) => { if (section === '用户偏好') throw new Error('disk full'); }); const consolidator = new MemoryConsolidator(adapter, deps.workspace); const result = await consolidator.consolidate('q', 'a', []); expect(result.appended).toBe(1); expect(result.skipped).toBe(1); expect(deps.appendMemory).toHaveBeenCalledWith('重要决策', 'second'); }); it('appendMemory 失败的条目不写入 semantic_memories(双轨一致性)', async () => { const { adapter } = makeAdapter(JSON.stringify([{ section: '用户偏好', entry: 'boom' }])); const deps = makeDeps(); deps.appendMemory.mockImplementation(() => { throw new Error('disk full'); }); const consolidator = new MemoryConsolidator(adapter, deps.workspace); await consolidator.consolidate('q', 'a', []); expect(deps.store).not.toHaveBeenCalled(); }); }); describe('MemoryConsolidator — 运行状态与退出等待', () => { it('空闲时 isRunning=false、waitForCompletion 立即返回 true', async () => { const { adapter } = makeAdapter('[]'); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); expect(consolidator.isRunning()).toBe(false); expect(await consolidator.waitForCompletion(100)).toBe(true); }); it('运行中 isRunning=true,结束后自动清零(finally 清理)', async () => { const { adapter, send } = makeAdapter('[]'); let resolveSend: (v: { content: string }) => void = () => {}; send.mockImplementation( () => new Promise<{ content: string }>((resolve) => { resolveSend = resolve; }), ); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); const pending = consolidator.consolidate('q', 'a', []); await vi.waitFor(() => expect(consolidator.isRunning()).toBe(true)); resolveSend({ content: '[]' }); await pending; expect(consolidator.isRunning()).toBe(false); }); it('waitForCompletion 超时返回 false(退出时强制收口的契约)', async () => { const { adapter, send } = makeAdapter('[]'); // 挂起的 LLM 调用必须可通过 reject 释放 —— consolidate 内部 catch 吞掉错误后 // finally 清理 runningPromise,isRunning 回到 false let release!: () => void; send.mockImplementation( () => new Promise<{ content: string }>((_, reject) => { release = () => reject(new Error('released for test teardown')); }), ); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); const pending = consolidator.consolidate('q', 'a', []); expect(consolidator.isRunning()).toBe(true); // 30ms 内 LLM 未完成 → waitForCompletion 返回 false(不等待) expect(await consolidator.waitForCompletion(30)).toBe(false); expect(consolidator.isRunning()).toBe(true); // 任务仍在 release(); await pending; expect(consolidator.isRunning()).toBe(false); }); it('并发 consolidate:旧 promise 被 finalize 清理后不影响新任务状态', async () => { const { adapter, send } = makeAdapter('[]'); const deps = makeDeps(); const consolidator = new MemoryConsolidator(adapter, deps.workspace); send.mockResolvedValue({ content: '[]' }); await Promise.all([ consolidator.consolidate('q1', 'a1', []), consolidator.consolidate('q2', 'a2', []), ]); expect(consolidator.isRunning()).toBe(false); }); });