Files
thzxx 26169b7be4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s
feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照,
IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭,
与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀
按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身
(tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门
(file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断);
单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口)

P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播,
getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers
全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项
校验+设置页 JSON 输入, 远程 MCP 鉴权头可用)

P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性
前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码
激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见);
IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留)

P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层,
外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1
文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2

测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/
orchestrator/workspace.service/session-recorder/config-layering/secure-config/
network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。
测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK),
固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过;
Electron ABI 全量 737/737 零跳过
2026-08-30 00:09:25 +08:00

293 lines
11 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.
/**
* MemoryConsolidator 测试(v0.7.2 覆盖补齐 —— 此前零测试)
*
* 锁定会话结束记忆固化的核心契约:
* 1. LLM 提取 JSON → 白名单 section 过滤 → MEMORY.md 追加 + semantic_memories 双轨写入
* 2. 边界:markdown 代码围栏剥离 / section 白名单 / 单次最多 5 条 / 单条 500 截断
* 3. 失败语义:LLM 抛错 / 非 JSON / 空响应 → 静默降级,不冒泡
* 4. 退出等待:isRunning / waitForCompletionv0.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<typeof vi.fn>;
} {
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<typeof vi.fn>;
manager: MemoryManager;
store: ReturnType<typeof vi.fn>;
} {
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 清理 runningPromiseisRunning 回到 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);
});
});