背景:v0.5.0 发布后对工具调用链路(adapter 流式 tool_call → 引擎 PARSING → preToolHooks 管道 → ToolRegistry → 结果回填)做全链路复检,发现并修复两处问题。 安全修复: - SubAgent 中止时 pending 确认未清理(安全回归):SubEngine 以 taskId 为 sessionId 写入 ConfirmationHook,abortSession 的 clearPending(sessionId) 清不到它们。后果:中止会话后残留弹框若被补批,孤儿工具会真实执行副作用 (v0.4.x 的全局清空反而能拦截)。修复:abortByParent 返回被中止的 taskId 列表,abortSession 一并 clearPending(taskId)。 确认弹框会话隔离(前端对齐后端 v0.5.0 语义): - ConfirmationRequest 新增 sessionId 字段(主会话为 sessionId,SubAgent 为 taskId),弹框在会话 INIT/TERMINATED 时只清除该会话的请求 —— 修复并发 会话下任意会话结束误清其他会话等待中确认的问题 - 选中计数按当前 requests 收敛(selectedIds 残留 id 无害化) 测试(207 → 215 用例): - 新增引擎级工具调用链路集成测试 ×6(engine-toolchain.test.ts):真实 PermissionCheckHook + RateLimitHook + ConfirmationHook 管道 + 真实 ToolRegistry,覆盖 SAFE 工具直通 / HIGH 工具批准执行 / 拒绝以 Blocked 错误回传 LLM / 会话隔离端到端 / 工具异常不中断循环 / 策略拦截系统路径 - 新增 FTS 触发器 × NULL content 删除安全性测试 ×2:验证 truncateAfter (编辑重发/重新生成)删除 content=NULL 的 assistant 消息(模型仅发 tool_calls 的标准场景)不抛错且索引保持一致 - agent.test.ts 的 orchestrator mock 适配 abortByParent 新返回类型 验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 215 全过 / build 成功
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
/**
|
||
* FTS5 会话内容搜索 + 审计日志导出 回归测试(v0.5.0)
|
||
*
|
||
* 覆盖:
|
||
* 1. messages_fts 虚表创建与触发器实时同步(INSERT / DELETE)
|
||
* 2. searchMessages:短语命中、跨会话聚合、FTS 运算符输入安全、snippet 生成
|
||
* 3. 审计导出:JSONL 行格式、CSV RFC 4180 转义(逗号/引号/换行)
|
||
*
|
||
* 运行要求:better-sqlite3 为 Electron ABI 构建,需 test:electron 模式
|
||
* (cross-env ELECTRON_RUN_AS_NODE=1 electron ...)执行;系统 Node 下自动跳过。
|
||
*/
|
||
|
||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||
|
||
vi.mock('electron-log', () => ({
|
||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||
}));
|
||
|
||
import { mkdtempSync, rmSync } from 'fs';
|
||
import { tmpdir } from 'os';
|
||
import { join } from 'path';
|
||
|
||
let dbAvailable = true;
|
||
let Database: typeof import('better-sqlite3');
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
Database = require('better-sqlite3');
|
||
const probe = new Database(':memory:');
|
||
probe.close();
|
||
} catch {
|
||
dbAvailable = false;
|
||
}
|
||
|
||
describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => {
|
||
let db: any;
|
||
let dir: string;
|
||
let sessionService: any;
|
||
|
||
beforeAll(async () => {
|
||
const { SessionService } = await import('../session.service');
|
||
dir = mkdtempSync(join(tmpdir(), 'metona-search-'));
|
||
db = new Database(':memory:');
|
||
// 与 DatabaseService.createTables 一致的最小 schema(含 FTS 虚表 + 触发器)
|
||
db.exec(`
|
||
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 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
|
||
);
|
||
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
|
||
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
|
||
END;
|
||
CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN
|
||
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
||
END;
|
||
`);
|
||
sessionService = new SessionService(() => db);
|
||
});
|
||
|
||
afterAll(() => {
|
||
try {
|
||
db?.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
|
||
const insertMessage = (
|
||
id: string,
|
||
sessionId: string,
|
||
role: string,
|
||
content: string | null,
|
||
ts: number,
|
||
): void => {
|
||
db.prepare(
|
||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||
).run(id, sessionId, role, content, ts);
|
||
};
|
||
|
||
it('FTS5 可用且插入消息实时进入索引', () => {
|
||
// FTS5 编译可用性(better-sqlite3 预编译含 FTS5,此处防御性验证)
|
||
insertMessage('m1', 's1', 'user', 'Hello FTS5 world', 1000);
|
||
const rows = db
|
||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'hello'`)
|
||
.all() as Array<{ c: number }>;
|
||
expect(rows[0].c).toBe(1);
|
||
});
|
||
|
||
it('searchMessages 按会话聚合返回(最新匹配 + 匹配计数 + snippet)', () => {
|
||
db.prepare(
|
||
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s1', '会话一', 1, 1)`,
|
||
).run();
|
||
db.prepare(
|
||
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s2', '会话二', 1, 1)`,
|
||
).run();
|
||
insertMessage('m2', 's1', 'assistant', 'uniquekeyword 出现了一次', 2000);
|
||
insertMessage('m3', 's2', 'user', 'uniquekeyword 在另一个会话', 3000);
|
||
insertMessage('m4', 's2', 'assistant', 'uniquekeyword 再次出现', 4000);
|
||
|
||
const results = sessionService.searchMessages('uniquekeyword');
|
||
expect(results).toHaveLength(2);
|
||
// s2 的最新匹配(m4)先返回(按时间倒序聚合)
|
||
expect(results[0].sessionId).toBe('s2');
|
||
expect(results[0].matchCount).toBe(2);
|
||
expect(results[0].lastMessageId).toBe('m4');
|
||
expect(results[0].sessionTitle).toBe('会话二');
|
||
expect(results[0].snippet).toContain('uniquekeyword');
|
||
// s1 单条匹配
|
||
expect(results[1].sessionId).toBe('s1');
|
||
expect(results[1].matchCount).toBe(1);
|
||
});
|
||
|
||
it('删除消息后索引同步(搜索不再命中)', () => {
|
||
db.prepare(`DELETE FROM messages WHERE id = 'm2'`).run();
|
||
const results = sessionService.searchMessages('uniquekeyword');
|
||
expect(results).toHaveLength(1);
|
||
expect(results[0].sessionId).toBe('s2');
|
||
});
|
||
|
||
it('FTS 运算符/引号输入安全(不抛错,按短语匹配)', () => {
|
||
insertMessage('m5', 's1', 'user', '包含 AND OR NOT 与 "引号" 的消息', 5000);
|
||
// 含 FTS 保留字与引号的查询不抛异常
|
||
expect(() => sessionService.searchMessages('AND OR NOT')).not.toThrow();
|
||
expect(() => sessionService.searchMessages('x"y')).not.toThrow();
|
||
// 引号内容作为短语可命中
|
||
const hit = sessionService.searchMessages('引号');
|
||
expect(hit.some((r: any) => r.lastMessageId === 'm5')).toBe(true);
|
||
});
|
||
|
||
it('空查询与无匹配返回空数组', () => {
|
||
expect(sessionService.searchMessages('')).toHaveLength(0);
|
||
expect(sessionService.searchMessages(' ')).toHaveLength(0);
|
||
expect(sessionService.searchMessages('不存在的关键词xyz')).toHaveLength(0);
|
||
});
|
||
|
||
it('NULL content 消息不进索引(assistant 仅有 tool_calls 场景)', () => {
|
||
insertMessage('m6', 's1', 'assistant', null, 6000);
|
||
const rows = db
|
||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'nulltest'`)
|
||
.all() as Array<{ c: number }>;
|
||
expect(rows[0].c).toBe(0);
|
||
// FTS 行数与 messages 行数一致(NULL 行在 FTS 中占位但不索引内容)
|
||
const fts = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number }).c;
|
||
const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
|
||
expect(fts).toBe(msg);
|
||
});
|
||
|
||
it('删除 NULL content 消息不抛错且索引保持一致(truncateAfter 安全性)', () => {
|
||
// 模拟真实会话混合场景:user 文本 + assistant(NULL, 仅 tool_calls) + tool 结果 + assistant 文本
|
||
insertMessage('m7', 's3', 'user', 'ftsdelete-test 用户消息', 7000);
|
||
insertMessage('m8', 's3', 'assistant', null, 7100);
|
||
insertMessage('m9', 's3', 'tool', '工具结果包含 ftsdelete-test 关键词', 7200);
|
||
insertMessage('m10', 's3', 'assistant', '最终回答', 7300);
|
||
db.prepare(
|
||
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s3', '会话三', 1, 1)`,
|
||
).run();
|
||
|
||
const ftsBefore = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number })
|
||
.c;
|
||
const msgBefore = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
|
||
expect(ftsBefore).toBe(msgBefore);
|
||
|
||
// 删除 NULL content 行 — delete 触发器以 old.content=NULL 执行 'delete' 命令
|
||
expect(() => db.prepare(`DELETE FROM messages WHERE id = 'm8'`).run()).not.toThrow();
|
||
|
||
// 行数仍一致
|
||
const ftsAfter = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number })
|
||
.c;
|
||
const msgAfter = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
|
||
expect(ftsAfter).toBe(msgAfter);
|
||
expect(ftsAfter).toBe(ftsBefore - 1);
|
||
|
||
// 索引仍可正常查询(未被 NULL 删除破坏)
|
||
const hit = sessionService.searchMessages('ftsdelete-test');
|
||
expect(hit.some((r: any) => r.sessionId === 's3')).toBe(true);
|
||
});
|
||
|
||
it('混合 NULL/非 NULL 的批量删除(truncateAfter 全场景)不抛错', () => {
|
||
// 再插入一批混合消息
|
||
insertMessage('m11', 's3', 'assistant', null, 8000);
|
||
insertMessage('m12', 's3', 'user', 'ftsdelete-test 第二轮', 8100);
|
||
insertMessage('m13', 's3', 'assistant', null, 8200);
|
||
|
||
// 批量删除(模拟 truncateMessagesAfter 的 rowid 范围删除)
|
||
expect(() =>
|
||
db
|
||
.prepare(
|
||
`DELETE FROM messages WHERE session_id = 's3' AND rowid >= (SELECT rowid FROM messages WHERE id = 'm7')`,
|
||
)
|
||
.run(),
|
||
).not.toThrow();
|
||
|
||
// s3 全删后行数一致,其他会话索引不受影响
|
||
const fts = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number }).c;
|
||
const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
|
||
expect(fts).toBe(msg);
|
||
const s3Hit = sessionService.searchMessages('ftsdelete-test');
|
||
expect(s3Hit.every((r: any) => r.sessionId !== 's3')).toBe(true);
|
||
// 其他会话仍可搜索
|
||
expect(sessionService.searchMessages('uniquekeyword').length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!dbAvailable)('审计日志导出(v0.5.0)', () => {
|
||
let db: any;
|
||
let dir: string;
|
||
let auditService: any;
|
||
|
||
beforeAll(async () => {
|
||
const { AuditService } = await import('../audit.service');
|
||
dir = mkdtempSync(join(tmpdir(), 'metona-audit-export-'));
|
||
db = new Database(':memory:');
|
||
db.exec(`
|
||
CREATE TABLE audit_logs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT,
|
||
iteration INTEGER,
|
||
event_type TEXT NOT NULL,
|
||
actor TEXT NOT NULL DEFAULT 'system',
|
||
target TEXT NOT NULL,
|
||
details TEXT,
|
||
outcome TEXT,
|
||
duration_ms INTEGER,
|
||
created_at INTEGER NOT NULL,
|
||
prev_hash TEXT,
|
||
current_hash TEXT
|
||
);
|
||
`);
|
||
auditService = new AuditService(() => db);
|
||
});
|
||
|
||
afterAll(() => {
|
||
try {
|
||
db?.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
|
||
it('exportJSONL 每行一条 JSON 记录(含链式哈希字段)', () => {
|
||
auditService.log({
|
||
sessionId: 's1',
|
||
eventType: 'tool_call',
|
||
actor: 'agent',
|
||
target: 'run_command',
|
||
details: { command: 'ls' },
|
||
outcome: 'success',
|
||
durationMs: 10,
|
||
});
|
||
auditService.log({
|
||
sessionId: 's1',
|
||
eventType: 'session_end',
|
||
actor: 'agent',
|
||
target: 'session',
|
||
outcome: 'success',
|
||
});
|
||
|
||
const jsonl = auditService.exportJSONL();
|
||
const lines = jsonl.trim().split('\n');
|
||
expect(lines).toHaveLength(2);
|
||
const first = JSON.parse(lines[0]);
|
||
expect(first.event_type).toBe('tool_call');
|
||
expect(first.session_id).toBe('s1');
|
||
expect(first.current_hash).toMatch(/^[0-9a-f]{64}$/);
|
||
// 第二条 prev_hash 等于第一条 current_hash(链完整保留在导出中)
|
||
const second = JSON.parse(lines[1]);
|
||
expect(second.prev_hash).toBe(first.current_hash);
|
||
});
|
||
|
||
it('exportCSV 含表头且 RFC 4180 转义(逗号/引号/换行字段)', () => {
|
||
auditService.log({
|
||
sessionId: 's2',
|
||
eventType: 'error',
|
||
actor: 'agent',
|
||
target: 'target,with,commas',
|
||
details: { msg: '含"双引号"和\n换行' },
|
||
outcome: 'error',
|
||
});
|
||
|
||
const csv = auditService.exportCSV();
|
||
const lines = csv.split('\n');
|
||
// 表头
|
||
expect(lines[0]).toBe(
|
||
'id,session_id,iteration,event_type,actor,target,details,outcome,duration_ms,created_at,prev_hash,current_hash',
|
||
);
|
||
// 含特殊字符的 target 字段被双引号包裹
|
||
expect(csv).toContain('"target,with,commas"');
|
||
// details 存储为 JSON 字符串(内部双引号为 \" 转义),CSV 再转义后为 \""(反斜杠保留 + 引号翻倍)
|
||
expect(csv).toContain('\\""双引号\\""');
|
||
// 记录数与 JSONL 一致
|
||
const parsed = auditService
|
||
.exportJSONL()
|
||
.trim()
|
||
.split('\n')
|
||
.map((l: string) => JSON.parse(l));
|
||
expect(parsed.length).toBeGreaterThanOrEqual(3);
|
||
});
|
||
});
|