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 零跳过
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 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<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 清理 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user