Files
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

426 lines
17 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.
/**
* 数据库 schema 版本化测试(v0.6.4 P3-3
*
* 契约:
* 1. 全新库 initialize() 后 PRAGMA user_version 盖章为 DatabaseService.SCHEMA_VERSION
* 2. 已盖章的库再次 initialize(新实例、同文件)→ 走快速路径,探测批次跳过,
* 且版本号保持不变;
* 3. user_version 与数据共存:盖章不会丢失/改变已有数据。
*
* 运行要求:better-sqlite3 需为 Electron ABI 构建(test:electron 模式),
* 系统 Node 下自动跳过。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
let dbAvailable = true;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const probe = require('better-sqlite3');
const p = new probe(':memory:');
p.close();
} catch {
dbAvailable = false;
}
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;
let svc1: DatabaseService;
let svc2: DatabaseService;
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'metona-dbmig-'));
const mod = await import('../database.service');
const Svc = mod.DatabaseService;
svc1 = new Svc(dir);
svc1.initialize();
svc2 = new Svc(dir); // 模拟第二次启动
svc2.initialize();
});
afterAll(() => {
try {
svc2?.close();
svc1?.close();
} catch {
/* ignore */
}
rmSync(dir, { recursive: true, force: true });
});
it('initialize 后 user_version 已盖章为 SCHEMA_VERSION', () => {
const version = svc2.getDB().pragma('user_version', { simple: true }) as number;
// v0.7.4 P4-4: SCHEMA_VERSION 1 → 2(迁移 9 trigram 纳入版本化)
expect(version).toBe(DatabaseService.SCHEMA_VERSION);
});
it('盖章库的探测批次被跳过(重复 initialize 不报错且配置 seed 保持存在)', () => {
// 核心断言在日志侧难以捕获 —— 这里验证幂等性的可观察结果:
// 种子默认值仍然存在(INSERT OR IGNORE),表结构与首次一致
const row = svc2.getDB().prepare(`SELECT COUNT(*) AS n FROM app_config`).get() as { n: number };
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 的库:增量迁移至 SCHEMA_VERSIONv0.8.0 补 sessions.deleted_at', async () => {
const { DatabaseService } = await import('../database.service');
const dir = mkdtempSync(join(tmpdir(), 'metona-migv2-'));
const legacy = openLegacyDb(dir);
// v0.8.0 注: 真实 v2 库必含 sessions 表 —— fixture 补齐以覆盖迁移 10
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 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='trigram');
`);
legacy.pragma('user_version = 2');
legacy.close();
const svc = new DatabaseService(dir);
svc.initialize();
const db = svc.getDB();
// v0.8.0 P2-1 契约更新: SCHEMA_VERSION 升至 3(迁移 10 sessions.deleted_at),
// user_version=2 的库不再是最新版 —— 会执行增量迁移并盖章到 3;
// FTS 已是 trigram(迁移 9 幂等跳过),增量迁移只补 sessions.deleted_at 列。
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
const delCol = db
.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('sessions') WHERE name='deleted_at'")
.get() as { c: number };
expect(delCol.c).toBe(1);
// 快速路径不重建 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');
});
});