Files
metona-ai-desktop/electron/services/__tests__/session-search.test.ts
T
thzxx c727ad075c
CI / 产物编译验证 (push) Successful in 10m9s
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m4s
CI / 全量测试 (Electron ABI) (push) Failing after 5m24s
fix: v0.5.1 工具调用链路复检修复 — SubAgent 孤儿工具拦截 + 确认弹框会话隔离
背景: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 成功
2026-08-21 21:47:03 +08:00

326 lines
12 KiB
TypeScript
Raw 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.
/**
* 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);
});
});