feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
@@ -12,7 +12,7 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
@@ -26,7 +26,16 @@ try {
dbAvailable = false;
}
import type { DatabaseService } from '../database.service';
import { DatabaseService } from '../database.service';
/** 预置 legacy 库:先建 .metona 目录再开库(与 DatabaseService 目录布局一致) */
function openLegacyDb(dir: string): any {
const metonaDir = join(dir, '.metona');
mkdirSync(metonaDir, { recursive: true });
// eslint-disable-next-line @typescript-eslint/no-require-imports
const raw = require('better-sqlite3');
return new raw(join(metonaDir, 'agent.db'));
}
describe.skipIf(!dbAvailable)('DatabaseService — PRAGMA user_version 版本化', () => {
let dir: string;
@@ -55,7 +64,8 @@ describe.skipIf(!dbAvailable)('DatabaseService — PRAGMA user_version 版本化
it('initialize 后 user_version 已盖章为 SCHEMA_VERSION', () => {
const version = svc2.getDB().pragma('user_version', { simple: true }) as number;
expect(version).toBe(1);
// v0.7.4 P4-4: SCHEMA_VERSION 1 → 2(迁移 9 trigram 纳入版本化)
expect(version).toBe(DatabaseService.SCHEMA_VERSION);
});
it('盖章库的探测批次被跳过(重复 initialize 不报错且配置 seed 保持存在)', () => {
@@ -65,3 +75,338 @@ describe.skipIf(!dbAvailable)('DatabaseService — PRAGMA user_version 版本化
expect(row.n).toBeGreaterThan(0);
});
});
describe.skipIf(!dbAvailable)('DatabaseService — 全量表结构与索引', () => {
let dir: string;
let svc: DatabaseService;
let db: any;
beforeAll(async () => {
const { DatabaseService } = await import('../database.service');
dir = mkdtempSync(join(tmpdir(), 'metona-migtables-'));
svc = new DatabaseService(dir);
svc.initialize();
db = svc.getDB();
});
afterAll(() => {
try {
svc?.close();
} catch {
/* ignore */
}
rmSync(dir, { recursive: true, force: true });
});
it('messages 表包含 FTS 所需的全部列(content 可 NULL + attachments + reasoning_content', () => {
const cols = db.prepare('PRAGMA table_info(messages)').all() as Array<{
name: string;
notnull: number;
}>;
const names = cols.map((c) => c.name);
for (const expected of [
'id',
'session_id',
'role',
'content',
'reasoning_content',
'tool_calls',
'tool_result',
'attachments',
'iteration',
'created_at',
]) {
expect(names).toContain(expected);
}
const contentCol = cols.find((c) => c.name === 'content');
expect(contentCol!.notnull).toBe(0); // C-6: content 允许 NULL
});
it('messages_fts 虚表使用 trigram tokenizer(迁移 9 目标 schema', () => {
const sqlRow = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
.get() as { sql: string };
expect(sqlRow.sql).toContain('trigram');
});
it('FTS 触发器存活:insert/delete/update 三个触发器', () => {
const triggers = db
.prepare(
`SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='messages' ORDER BY name`,
)
.all() as Array<{ name: string }>;
const names = triggers.map((t) => t.name);
expect(names).toContain('messages_fts_insert');
expect(names).toContain('messages_fts_delete');
expect(names).toContain('messages_fts_update');
});
it('FTS 实时同步:插入消息后索引可见、删除后索引同步', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('mig_s1', 's', 1, 1)`,
).run();
db.prepare(
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES ('mig_m1', 'mig_s1', 'user', 'migftshit 内容', 1)`,
).run();
const hit = db
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'migftsh'`)
.get() as { c: number };
expect(hit.c).toBe(1);
db.prepare(`DELETE FROM messages WHERE id = 'mig_m1'`).run();
const miss = db
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'migftsh'`)
.get() as { c: number };
expect(miss.c).toBe(0);
});
it('audit_logs 的 INSERT-ONLY 触发器存活(audit_no_update / audit_no_delete', () => {
const triggers = db
.prepare(
`SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='audit_logs' ORDER BY name`,
)
.all() as Array<{ name: string }>;
const names = triggers.map((t) => t.name);
expect(names).toContain('audit_no_update');
expect(names).toContain('audit_no_delete');
// 更新被拒绝
db.prepare(
`INSERT INTO audit_logs (session_id, event_type, actor, target, created_at) VALUES ('s', 'error', 'agent', 't', 1)`,
).run();
expect(() =>
db.prepare(`UPDATE audit_logs SET target = 'hacked' WHERE event_type = 'error'`).run(),
).toThrow();
});
it('episodic/semantic/working 记忆表均含 tf_cache 列(P2-12', () => {
for (const table of ['episodic_memories', 'semantic_memories', 'working_memories']) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
expect(cols.map((c) => c.name)).toContain('tf_cache');
}
});
it('session_summaries 外键为 ON DELETE CASCADE(迁移 8', () => {
const fks = db.prepare('PRAGMA foreign_key_list(session_summaries)').all() as Array<{
table: string;
on_delete: string;
}>;
expect(fks.some((fk) => fk.table === 'sessions' && fk.on_delete === 'CASCADE')).toBe(true);
});
it('mcp_servers transport CHECK 含 streamable-http(迁移 6', () => {
const sqlRow = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='mcp_servers'`)
.get() as { sql: string };
expect(sqlRow.sql).toContain('streamable-http');
});
it('CONFIG_DEFAULTS 全部 seed 入库(每个默认 key 一行)', async () => {
const { CONFIG_DEFAULTS } = await import('../database.service');
const total = db.prepare('SELECT COUNT(*) AS c FROM app_config').get() as { c: number };
expect(total.c).toBeGreaterThanOrEqual(CONFIG_DEFAULTS.length);
// 抽查默认 key 存在
const keys = db.prepare('SELECT key FROM app_config').all() as Array<{ key: string }>;
const keySet = new Set(keys.map((k) => k.key));
for (const d of CONFIG_DEFAULTS) {
expect(keySet.has(d.key)).toBe(true);
}
});
it('索引覆盖常用查询路径(idx_messages_session 复合索引存在)', () => {
const idx = db.prepare(`PRAGMA index_list(messages)`).all() as Array<{ name: string }>;
expect(idx.map((i) => i.name)).toContain('idx_messages_session');
});
});
describe.skipIf(!dbAvailable)('DatabaseService — 多版本迁移矩阵(user_version 0/1/2', () => {
it('user_version=0 的旧库:初始化后完整迁移并盖章为 SCHEMA_VERSION', async () => {
const { DatabaseService } = await import('../database.service');
const dir = mkdtempSync(join(tmpdir(), 'metona-migv0-'));
// 预置一个"旧版"库:只有消息表 + 非 trigram FTS(模拟迁移 9 之前)
const legacy = openLegacyDb(dir);
legacy.exec(`
CREATE TABLE messages (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
content TEXT NOT NULL, reasoning_content TEXT, tool_calls TEXT,
tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL
);
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='unicode61');
`);
legacy.pragma('user_version = 0');
legacy.close();
const svc = new DatabaseService(dir);
svc.initialize();
const db = svc.getDB();
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
// 迁移 9FTS 重建为 trigram
const ftsSql = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
.get() as { sql: string };
expect(ftsSql.sql).toContain('trigram');
// 迁移 5messages content 允许 NULL
const cols = db.prepare('PRAGMA table_info(messages)').all() as Array<{
notnull: number;
name: string;
}>;
const contentCol = cols.find((c) => c.name === 'content');
expect(contentCol!.notnull).toBe(0);
svc.close();
rmSync(dir, { recursive: true, force: true });
});
it('user_version=1 的库(迁移 9 之前盖章):重新初始化补齐 trigram', async () => {
const { DatabaseService } = await import('../database.service');
const dir = mkdtempSync(join(tmpdir(), 'metona-migv1-'));
const legacy = openLegacyDb(dir);
legacy.exec(`
CREATE TABLE messages (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
content TEXT NOT NULL, reasoning_content TEXT, tool_calls TEXT,
tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL
);
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='unicode61');
`);
legacy.pragma('user_version = 1'); // 旧版本盖章但缺迁移 9
legacy.close();
const svc = new DatabaseService(dir);
svc.initialize();
const db = svc.getDB();
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
const ftsSql = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
.get() as { sql: string };
expect(ftsSql.sql).toContain('trigram');
svc.close();
rmSync(dir, { recursive: true, force: true });
});
it('user_version=2 的库:快速路径跳过迁移,版本号保持不变', async () => {
const { DatabaseService } = await import('../database.service');
const dir = mkdtempSync(join(tmpdir(), 'metona-migv2-'));
const legacy = openLegacyDb(dir);
legacy.exec(`
CREATE TABLE messages (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
content TEXT, created_at INTEGER NOT NULL
);
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='trigram');
`);
legacy.pragma('user_version = 2');
legacy.close();
const svc = new DatabaseService(dir);
svc.initialize();
const db = svc.getDB();
expect(db.pragma('user_version', { simple: true })).toBe(2);
// 快速路径不重建 FTS(保持原有 trigram 表)
const ftsSql = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
.get() as { sql: string };
expect(ftsSql.sql).toContain('trigram');
svc.close();
rmSync(dir, { recursive: true, force: true });
});
it('迁移 9 幂等:重复 initialize 不重复重建(SQL 始终为 trigram 单份定义)', async () => {
const { DatabaseService } = await import('../database.service');
const dir = mkdtempSync(join(tmpdir(), 'metona-migidem-'));
const svc = new DatabaseService(dir);
svc.initialize();
svc.close();
const svc2 = new DatabaseService(dir);
svc2.initialize();
const db = svc2.getDB();
const ftsRows = db
.prepare(`SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
.get() as { c: number };
expect(ftsRows.c).toBe(1);
const sqlRow = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
.get() as { sql: string };
expect(sqlRow.sql).toContain('trigram');
svc2.close();
rmSync(dir, { recursive: true, force: true });
});
it('迁移 7 FTS 回填:已有消息在 initialize 后进入索引(rebuild 语义)', async () => {
const { DatabaseService } = await import('../database.service');
const dir = mkdtempSync(join(tmpdir(), 'metona-migbackfill-'));
const legacy = openLegacyDb(dir);
// 旧库:messages 有数据但 messages_fts 空(无回填)
legacy.exec(`
CREATE TABLE messages (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
content TEXT NOT NULL, reasoning_content TEXT, tool_calls TEXT,
tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}'
);
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='unicode61');
`);
legacy.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('s1', 1, 1)`).run();
legacy
.prepare(
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES ('m1', 's1', 'user', 'backfillkeyword 数据', 1)`,
)
.run();
legacy.close();
const svc = new DatabaseService(dir);
svc.initialize();
const db = svc.getDB();
// 迁移 9 重建 + rebuild 后,存量消息应可被搜索
const hit = db
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'backfillkeyw'`)
.get() as { c: number };
expect(hit.c).toBe(1);
svc.close();
rmSync(dir, { recursive: true, force: true });
});
});
describe.skipIf(!dbAvailable)('DatabaseService — 种子配置与连接语义', () => {
let dir: string;
let svc: DatabaseService;
beforeAll(async () => {
const { DatabaseService } = await import('../database.service');
dir = mkdtempSync(join(tmpdir(), 'metona-migseed-'));
svc = new DatabaseService(dir);
svc.initialize();
});
afterAll(() => {
try {
svc?.close();
} catch {
/* ignore */
}
rmSync(dir, { recursive: true, force: true });
});
it('敏感默认 key 以明文空串 seed(新用户未配置),category 正确', () => {
const row = svc
.getDB()
.prepare(`SELECT value, category FROM app_config WHERE key = 'llm.apiKey'`)
.get() as { value: string; category: string };
expect(JSON.parse(row.value)).toBe('');
expect(row.category).toBe('llm');
});
it('数字/布尔默认值 seed 为 JSON 类型', () => {
const row = svc
.getDB()
.prepare(`SELECT value FROM app_config WHERE key = 'agent.maxIterations'`)
.get() as { value: string };
expect(JSON.parse(row.value)).toBe(20);
});
it('WAL 模式已开启(journal_mode=wal', () => {
const mode = svc.getDB().pragma('journal_mode', { simple: true }) as string;
expect(mode).toBe('wal');
});
});