Files
metona-ai-desktop/electron/services/__tests__/session-search.test.ts
T
thzxx 99d0c54129
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
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)
2026-08-30 19:19:07 +08:00

1019 lines
40 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,
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='trigram'
);
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);
});
// v0.7.4 P4-4: trigram 中文子串搜索(messages_fts trigram tokenizer
it('trigram 中文子串搜索:≥3 字查询命中非连续子串(P4-4 根治)', () => {
insertMessage('m14', 's1', 'user', '人工智能正在改变世界', 7000);
// 3 字子串「人工智」/「能正在」—— trigram 任意子串命中
const hit1 = sessionService.searchMessages('人工智');
expect(hit1.some((r: any) => r.lastMessageId === 'm14')).toBe(true);
const hit2 = sessionService.searchMessages('智能正');
expect(hit2.some((r: any) => r.lastMessageId === 'm14')).toBe(true);
// 4 字整串命中
const hit3 = sessionService.searchMessages('人工智能');
expect(hit3.some((r: any) => r.lastMessageId === 'm14')).toBe(true);
// 2 字中文走 LIKE 回退(trigram 无法匹配 2 字)
const hit4 = sessionService.searchMessages('正在');
expect(hit4.some((r: any) => r.lastMessageId === 'm14')).toBe(true);
// 不存在的子串不命中
expect(sessionService.searchMessages('机器学习')).toHaveLength(0);
});
// v0.7.4 P4-4 修正: ≤2 字符查询(trigram 无法构成 3-gramLIKE 回退
it('≤2 字符查询(2 字英文/单字符)LIKE 回退命中(P4-4 修正)', () => {
insertMessage('m15', 's1', 'user', 'AI 助手 ok 完成', 9000);
// 2 字英文:trigram 0 命中,LIKE 回退
const hit1 = sessionService.searchMessages('AI');
expect(hit1.some((r: any) => r.lastMessageId === 'm15')).toBe(true);
// 2 字英文小写
const hit2 = sessionService.searchMessages('ok');
expect(hit2.some((r: any) => r.lastMessageId === 'm15')).toBe(true);
// 单字符英文
const hit3 = sessionService.searchMessages('o');
expect(hit3.some((r: any) => r.lastMessageId === 'm15')).toBe(true);
// 2 字中文 LIKE 回退仍生效
const hit4 = sessionService.searchMessages('助手');
expect(hit4.some((r: any) => r.lastMessageId === 'm15')).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)('SessionService — 会话 CRUD 与消息边界', () => {
let db: any;
let sessionService: any;
beforeAll(async () => {
const { SessionService } = await import('../session.service');
db = new Database(':memory:');
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,
total_tokens 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,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
attachments TEXT,
iteration INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER
);
CREATE TABLE session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
sessionService = new SessionService(() => db);
});
afterAll(() => {
try {
db?.close();
} catch {
/* ignore */
}
});
const createSession = (id: string, title = '会话'): void => {
db.prepare(`INSERT INTO sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`).run(
id,
title,
Date.now(),
Date.now(),
);
};
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('getSession 返回完整会话信息(pinned/archived 布尔映射)', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at, message_count, total_tokens, pinned, archived)
VALUES ('crud_s1', 'CRUD 会话', 1, 2, 5, 123, 1, 0)`,
).run();
const info = sessionService.getSession('crud_s1');
expect(info).toMatchObject({
id: 'crud_s1',
title: 'CRUD 会话',
messageCount: 5,
totalTokens: 123,
pinned: true,
archived: false,
});
});
it('getSession 不存在的会话返回 null', () => {
expect(sessionService.getSession('crud_nope')).toBeNull();
});
it('getSession 对归档会话仍可查询(归档不隐藏,P1-4 契约)', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at, archived) VALUES ('crud_arch', '归档会话', 1, 1, 1)`,
).run();
expect(sessionService.getSession('crud_arch')).not.toBeNull();
});
it('list 默认只返回未归档会话,按 pinned 优先 + updated_at 倒序', () => {
createSession('crud_l1', '普通');
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at, pinned) VALUES ('crud_l2', '置顶', 1, 10, 1)`,
).run();
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at, archived) VALUES ('crud_l3', '归档', 1, 20, 1)`,
).run();
const list = sessionService.list();
const ids = list.map((s: any) => s.id);
expect(ids).toContain('crud_l2');
expect(ids).not.toContain('crud_l3');
// 置顶的排最前
expect(ids[0]).toBe('crud_l2');
});
it('list({ archived: true }) 只返回归档会话', () => {
const list = sessionService.list({ archived: true });
expect(list.every((s: any) => s.archived)).toBe(true);
});
it('rename 成功返回 true 且标题更新;不存在返回 false', () => {
createSession('crud_r1', '旧标题');
expect(sessionService.rename('crud_r1', '新标题')).toBe(true);
expect(sessionService.getSession('crud_r1').title).toBe('新标题');
expect(sessionService.rename('crud_r2_missing', 'x')).toBe(false);
});
it('pin 置顶/取消置顶并返回布尔', () => {
createSession('crud_p1');
expect(sessionService.pin('crud_p1', true)).toBe(true);
expect(sessionService.getSession('crud_p1').pinned).toBe(true);
expect(sessionService.pin('crud_p1', false)).toBe(true);
expect(sessionService.getSession('crud_p1').pinned).toBe(false);
expect(sessionService.pin('crud_p_missing', true)).toBe(false);
});
it('archive 归档/取消归档并返回布尔', () => {
createSession('crud_a1');
expect(sessionService.archive('crud_a1', true)).toBe(true);
expect(sessionService.getSession('crud_a1').archived).toBe(true);
expect(sessionService.archive('crud_a1', false)).toBe(true);
expect(sessionService.getSession('crud_a1').archived).toBe(false);
});
it('delete 级联删除消息(ON DELETE CASCADE 前提)并返回布尔', () => {
// 本 schema 无 FK,直接验证 messages 被删除的调用方语义(真实库有 CASCADE)
createSession('crud_del', '将被删除');
insertMessage('crud_del_m1', 'crud_del', 'user', '消息', 1);
expect(sessionService.delete('crud_del')).toBe(true);
expect(sessionService.getSession('crud_del')).toBeNull();
expect(sessionService.delete('crud_del_missing')).toBe(false);
});
it('getMessages 默认全部返回(按 rowid 升序)', () => {
createSession('crud_msg', '消息边界');
insertMessage('crud_msg_m1', 'crud_msg', 'user', '第一条', 1);
insertMessage('crud_msg_m2', 'crud_msg', 'assistant', '第二条', 2);
insertMessage('crud_msg_m3', 'crud_msg', 'user', '第三条', 3);
const messages = sessionService.getMessages('crud_msg');
expect(messages.map((m: any) => m.content)).toEqual(['第一条', '第二条', '第三条']);
});
it('getMessages 分页:limit + offset', () => {
const page = sessionService.getMessages('crud_msg', { limit: 2, offset: 1 });
expect(page.map((m: any) => m.content)).toEqual(['第二条', '第三条']);
});
it('getMessages afterRowid 游标:仅返回 rowid 更大的消息', () => {
const second = db
.prepare(`SELECT rowid AS rid FROM messages WHERE id = 'crud_msg_m2'`)
.get() as { rid: number };
const after = sessionService.getMessages('crud_msg', { afterRowid: second.rid });
expect(after.map((m: any) => m.content)).toEqual(['第三条']);
});
it('getMessages 返回 rowId 字段(截断/摘要游标语义)', () => {
const messages = sessionService.getMessages('crud_msg');
expect(messages[0].rowId).toBeGreaterThan(0);
});
it('saveMessage 持久化全部字段并回读(toolCalls/attachments JSON 序列化)', () => {
createSession('crud_save', '保存消息');
const saved = sessionService.saveMessage({
sessionId: 'crud_save',
role: 'assistant',
content: null,
reasoningContent: '思考过程',
toolCalls: [{ name: 'read_file', args: { path: '/a' } }],
iteration: 3,
});
expect(saved.content).toBeNull();
expect(saved.toolCalls).toEqual([{ name: 'read_file', args: { path: '/a' } }]);
const loaded = sessionService.getMessages('crud_save');
expect(loaded[0].toolCalls).toEqual([{ name: 'read_file', args: { path: '/a' } }]);
expect(loaded[0].reasoningContent).toBe('思考过程');
expect(loaded[0].iteration).toBe(3);
});
it('saveMessage 累加会话 message_count 并更新 updated_at', () => {
createSession('crud_cnt', '计数');
sessionService.saveMessage({ sessionId: 'crud_cnt', role: 'user', content: '1' });
sessionService.saveMessage({ sessionId: 'crud_cnt', role: 'user', content: '2' });
const info = sessionService.getSession('crud_cnt');
expect(info.messageCount).toBe(2);
});
it('updateMessageContent 仅允许 user 角色:user 更新成功', () => {
insertMessage('crud_um1', 'crud_cnt', 'user', '原内容', 1);
expect(sessionService.updateMessageContent('crud_cnt', 'crud_um1', '新内容')).toBe(true);
const msg = sessionService.getMessages('crud_cnt').find((m: any) => m.id === 'crud_um1');
expect(msg.content).toBe('新内容');
});
it('updateMessageContent 拒绝非 user 角色消息(返回 false 不改写)', () => {
insertMessage('crud_um2', 'crud_cnt', 'assistant', '助手原话', 2);
expect(sessionService.updateMessageContent('crud_cnt', 'crud_um2', '篡改')).toBe(false);
const msg = sessionService.getMessages('crud_cnt').find((m: any) => m.id === 'crud_um2');
expect(msg.content).toBe('助手原话');
});
it('updateMessageContent 消息不存在或会话不匹配返回 false', () => {
expect(sessionService.updateMessageContent('crud_cnt', 'crud_ghost', 'x')).toBe(false);
expect(sessionService.updateMessageContent('crud_other_session', 'crud_um1', 'x')).toBe(false);
});
it('updateTokenUsage 累加 total_tokens(多次调用叠加)', () => {
createSession('crud_tok', 'Token 统计');
sessionService.updateTokenUsage('crud_tok', 100);
sessionService.updateTokenUsage('crud_tok', 250);
expect(sessionService.getSession('crud_tok').totalTokens).toBe(350);
});
it('updateTokenUsage 负数抵扣(token 计费冲正场景)', () => {
sessionService.updateTokenUsage('crud_tok', -50);
expect(sessionService.getSession('crud_tok').totalTokens).toBe(300);
});
it('saveTraceData/getTraceData 往返(traceSteps + tokenUsage 存入 metadata', () => {
createSession('crud_trace', 'Trace');
sessionService.saveTraceData('crud_trace', {
traceSteps: [{ id: 'step1', startedAt: 100 }],
tokenUsage: { input: 10, output: 20, total: 30 },
});
const data = sessionService.getTraceData('crud_trace');
expect(data).toEqual({
traceSteps: [{ id: 'step1', startedAt: 100 }],
tokenUsage: { input: 10, output: 20, total: 30 },
});
});
it('getTraceData 无 metadata 返回 null', () => {
createSession('crud_notrace', '无 Trace');
expect(sessionService.getTraceData('crud_notrace')).toBeNull();
});
it('getTraceData 损坏 metadata 返回 null(不抛错)', () => {
db.prepare(`UPDATE sessions SET metadata = '{broken' WHERE id = 'crud_notrace'`).run();
expect(sessionService.getTraceData('crud_notrace')).toBeNull();
});
it('getTraceData metadata 无 trace 字段时返回 null{} 或无关字段)', () => {
db.prepare(`UPDATE sessions SET metadata = '{"foo":1}' WHERE id = 'crud_notrace'`).run();
expect(sessionService.getTraceData('crud_notrace')).toBeNull();
});
it('truncateMessagesAfter:锚点为首条消息(inclusive)删除全部', () => {
createSession('crud_tr1', '截断一');
insertMessage('crud_tr1_a', 'crud_tr1', 'user', '首条', 1);
insertMessage('crud_tr1_b', 'crud_tr1', 'assistant', '次条', 2);
insertMessage('crud_tr1_c', 'crud_tr1', 'user', '末条', 3);
expect(sessionService.truncateMessagesAfter('crud_tr1', 'crud_tr1_a', true)).toBe(true);
expect(sessionService.getMessages('crud_tr1')).toHaveLength(0);
});
it('truncateMessagesAfter:锚点为末条(inclusive)仅删锚点', () => {
createSession('crud_tr2', '截断二');
insertMessage('crud_tr2_a', 'crud_tr2', 'user', '首条', 1);
insertMessage('crud_tr2_b', 'crud_tr2', 'assistant', '次条', 2);
insertMessage('crud_tr2_c', 'crud_tr2', 'user', '末条', 3);
expect(sessionService.truncateMessagesAfter('crud_tr2', 'crud_tr2_c', true)).toBe(true);
const remaining = sessionService.getMessages('crud_tr2');
expect(remaining).toHaveLength(2);
expect(remaining[1].content).toBe('次条');
});
it('truncateMessagesAfterinclusive=false 保留锚点本身', () => {
createSession('crud_tr3', '截断三');
insertMessage('crud_tr3_a', 'crud_tr3', 'user', '锚点', 1);
insertMessage('crud_tr3_b', 'crud_tr3', 'assistant', '后一条', 2);
insertMessage('crud_tr3_c', 'crud_tr3', 'user', '后两条', 3);
expect(sessionService.truncateMessagesAfter('crud_tr3', 'crud_tr3_a', false)).toBe(true);
const remaining = sessionService.getMessages('crud_tr3');
expect(remaining.map((m: any) => m.content)).toEqual(['锚点']);
});
it('truncateMessagesAfter:锚点不存在返回 false 且无副作用', () => {
createSession('crud_tr4', '截断四');
insertMessage('crud_tr4_a', 'crud_tr4', 'user', '唯一消息', 1);
const before = sessionService.getMessages('crud_tr4').length;
expect(sessionService.truncateMessagesAfter('crud_tr4', 'crud_ghost', true)).toBe(false);
expect(sessionService.getMessages('crud_tr4').length).toBe(before);
});
it('truncateMessagesAfterinclusive 变体对摘要游标的联动(游标落在删除区则删摘要)', () => {
createSession('crud_tr5', '截断五');
insertMessage('crud_tr5_a', 'crud_tr5', 'user', 'a', 1);
insertMessage('crud_tr5_b', 'crud_tr5', 'assistant', 'b', 2);
insertMessage('crud_tr5_c', 'crud_tr5', 'user', 'c', 3);
const cursor = (
db.prepare(`SELECT MAX(rowid) AS r FROM messages WHERE session_id = 'crud_tr5'`).get() as {
r: number;
}
).r;
db.prepare(
`INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at) VALUES ('crud_tr5', '摘要', ?, ?)`,
).run(cursor, Date.now());
// inclusive=true 删 b、crowid >= b 的 rowid)→ 游标被覆盖 → 摘要清除
expect(sessionService.truncateMessagesAfter('crud_tr5', 'crud_tr5_b', true)).toBe(true);
const summaries = db
.prepare(`SELECT session_id FROM session_summaries WHERE session_id = 'crud_tr5'`)
.all();
expect(summaries).toHaveLength(0);
});
it('clearMessages 清空消息、重置计数并置空 metadataB-2/A1 契约)', () => {
createSession('crud_clear', '清空');
insertMessage('crud_clear_a', 'crud_clear', 'user', '1', 1);
insertMessage('crud_clear_b', 'crud_clear', 'user', '2', 2);
sessionService.saveTraceData('crud_clear', { traceSteps: [{ id: 's' }], tokenUsage: {} });
expect(() => sessionService.clearMessages('crud_clear')).not.toThrow();
expect(sessionService.getMessages('crud_clear')).toHaveLength(0);
expect(sessionService.getSession('crud_clear').messageCount).toBe(0);
expect(sessionService.getTraceData('crud_clear')).toBeNull();
});
});
describe.skipIf(!dbAvailable)('searchMessages — 更多查询形态与边界', () => {
let db: any;
let sessionService: any;
beforeAll(async () => {
const { SessionService } = await import('../session.service');
db = new Database(':memory:');
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,
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='trigram'
);
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 */
}
});
const insertMessage = (
id: string,
sessionId: string,
role: string,
content: string,
ts: number,
): void => {
db.prepare(
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
).run(id, sessionId, role, content, ts);
};
it('英文短语(含空格)按整体短语命中', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q1', '短语会话', 1, 1)`,
).run();
insertMessage('q1_m1', 'q1', 'assistant', 'the quick brown fox jumps', 100);
insertMessage('q1_m2', 'q1', 'user', 'the lazy dog sleeps', 200);
// 2 字以上英文 → 引号短语匹配
const hits = sessionService.searchMessages('quick brown');
expect(hits.some((r: any) => r.lastMessageId === 'q1_m1')).toBe(true);
expect(hits.some((r: any) => r.lastMessageId === 'q1_m2')).toBe(false);
});
it('中英混排查询(无空格短语)可命中混合内容', () => {
insertMessage('q1_m3', 'q1', 'assistant', '配置 config修改 混合内容', 300);
const hits = sessionService.searchMessages('config修改');
expect(hits.some((r: any) => r.lastMessageId === 'q1_m3')).toBe(true);
});
it('FTS 保留字运算符被转义(AND/OR/NOT 不触发语法错误)', () => {
insertMessage('q1_m4', 'q1', 'user', 'and or not 关键字测试', 400);
expect(() => sessionService.searchMessages('and or not')).not.toThrow();
// 作为普通文本命中
const hits = sessionService.searchMessages('and or not');
expect(hits.some((r: any) => r.lastMessageId === 'q1_m4')).toBe(true);
});
it('查询中的星号/连字符不触发 FTS 语法错误', () => {
insertMessage('q1_m5', 'q1', 'user', 'version-2 star*text', 500);
expect(() => sessionService.searchMessages('version-2')).not.toThrow();
expect(() => sessionService.searchMessages('star*')).not.toThrow();
});
it('超长查询(>200 字符)不抛错且按短语匹配', () => {
const longContent = '超长内容 ' + 'abc '.repeat(80);
insertMessage('q1_m6', 'q1', 'user', longContent, 600);
const longQuery = 'abc '.repeat(30).trim();
expect(() => sessionService.searchMessages(longQuery)).not.toThrow();
});
it('scanLimit 边界:仅扫描前 N 条匹配(聚合结果受限制)', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q2', '扫描上限', 1, 1)`,
).run();
// 同一会话 5 条匹配 + 另一会话 1 条匹配
for (let i = 1; i <= 5; i++) {
insertMessage(`q2_m${i}`, 'q2', 'user', `scanlimit-hit 第 ${i} 次`, i * 10);
}
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q3', '另一个', 1, 1)`,
).run();
insertMessage('q3_m1', 'q3', 'user', 'scanlimit-hit 在别的会话', 999);
const full = sessionService.searchMessages('scanlimit-hit');
expect(full.length).toBe(2); // 两个会话都被聚合
// scanLimit=1:只扫描最新 1 条 → 仅 q3 被聚合(q3_m1 时间最新)
const limited = sessionService.searchMessages('scanlimit-hit', { scanLimit: 1 });
expect(limited).toHaveLength(1);
expect(limited[0].sessionId).toBe('q3');
});
it('maxSessions 边界:聚合结果被截断到上限', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q4', 's4', 1, 1)`,
).run();
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q5', 's5', 1, 1)`,
).run();
insertMessage('q4_m1', 'q4', 'user', 'maxsessions-hit', 10);
insertMessage('q5_m1', 'q5', 'user', 'maxsessions-hit', 20);
const capped = sessionService.searchMessages('maxsessions-hit', { maxSessions: 1 });
expect(capped).toHaveLength(1);
expect(capped[0].sessionId).toBe('q5'); // 时间最新的优先
});
it('matchCount 聚合计数正确(同一会话多次匹配)', () => {
const hits = sessionService.searchMessages('scanlimit-hit');
const q2 = hits.find((r: any) => r.sessionId === 'q2');
expect(q2.matchCount).toBe(5);
});
it('snippet 含高亮标记且截取自消息开头', () => {
const hits = sessionService.searchMessages('maxsessions-hit');
// snippet() 使用 '[','] ' 包裹命中的前 12 个 trigram token —— 命中词前缀可见
expect(hits[0].snippet).toContain('[maxsessions');
expect(hits[0].snippet).toContain(']');
});
it('2 字英文查询走 LIKE 回退且大小写不敏感', () => {
insertMessage('q6_m1', 'q6', 'user', 'SQLite database is good', 1);
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q6', 'like', 1, 1)`,
).run();
// 'sq' 为 2 字 → trigram 无法构成 3-gram → LIKE 回退;SQLite LIKE 对 ASCII 大小写不敏感
const hits = sessionService.searchMessages('sq');
expect(hits.some((r: any) => r.lastMessageId === 'q6_m1')).toBe(true);
});
it('LIKE 回退时 % 与 _ 通配符被转义(不误匹配任意内容)', () => {
insertMessage('q7_m1', 'q7', 'user', '进度 100% 完成', 1);
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q7', 'pct', 1, 1)`,
).run();
// 2 字查询 "%_" 中 % 与 _ 均被转义 → 仅匹配字面 "%_";若无转义会命中全部
const hits = sessionService.searchMessages('%_');
expect(hits).toHaveLength(0);
// 字面 % 命中(2 字英文 "% " 不可行 —— 用 "%、" 中文标点 + % 组合验证转义不报错)
expect(() => sessionService.searchMessages('100%')).not.toThrow();
});
it('归档会话仍参与搜索(搜索不因归档缺结果)', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at, archived) VALUES ('q8', '归档可搜', 1, 1, 1)`,
).run();
insertMessage('q8_m1', 'q8', 'user', 'archivedkeyword 归档内容', 1);
const hits = sessionService.searchMessages('archivedkeyword');
expect(hits.some((r: any) => r.sessionId === 'q8')).toBe(true);
});
it('已删除会话的标题回退为占位文案((已删除会话))', () => {
// 消息引用已不存在的会话 id(真实库 FK 会阻止,此处验证 title 补全兜底)
insertMessage('q9_m1', 'q9_ghost', 'user', 'ghostkeyword 消息', 1);
const hits = sessionService.searchMessages('ghostkeyword');
expect(hits.some((r: any) => r.sessionTitle === '(已删除会话)')).toBe(true);
});
it('单字符 CJK 查询走 LIKE 回退命中子串', () => {
insertMessage('q10_m1', 'q10', 'user', '编程语言爱', 1);
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q10', '单字', 1, 1)`,
).run();
const hits = sessionService.searchMessages('语');
expect(hits.some((r: any) => r.lastMessageId === 'q10_m1')).toBe(true);
});
it('结果按 created_at 倒序(最新匹配排前)', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q11', '排序', 1, 1)`,
).run();
insertMessage('q11_m1', 'q11', 'user', 'orderkeyword 早', 10);
insertMessage('q11_m2', 'q11', 'assistant', 'orderkeyword 晚', 20);
const hits = sessionService.searchMessages('orderkeyword');
expect(hits[0].lastMessageId).toBe('q11_m2');
expect(hits[0].lastRole).toBe('assistant');
});
it('相同内容多会话命中时返回多个聚合结果', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q12', '甲', 1, 1)`,
).run();
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q13', '乙', 1, 1)`,
).run();
insertMessage('q12_m1', 'q12', 'user', 'sharedkw 内容', 1);
insertMessage('q13_m1', 'q13', 'user', 'sharedkw 内容', 2);
const hits = sessionService.searchMessages('sharedkw');
expect(hits).toHaveLength(2);
});
});
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);
});
});
describe.skipIf(!dbAvailable)('saveMessage id 透传与 updateMessageContentR1 回归)', () => {
let db: any;
let sessionService: any;
beforeAll(async () => {
const { SessionService } = await import('../session.service');
db = new Database(':memory:');
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,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
attachments TEXT,
iteration INTEGER,
created_at INTEGER NOT NULL
);
`);
sessionService = new SessionService(() => db);
});
afterAll(() => {
try {
db?.close();
} catch {
/* ignore */
}
});
it('saveMessage 透传 id 时使用该 id 落库(R1 修复)', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('r1s', 'r1', 1, 1)`,
).run();
const saved = sessionService.saveMessage({
sessionId: 'r1s',
role: 'user',
content: '前端生成的消息',
id: 'custom-user-id',
});
expect(saved.id).toBe('custom-user-id');
const rows = db.prepare(`SELECT id FROM messages WHERE id = 'custom-user-id'`).all();
expect(rows).toHaveLength(1);
});
it('saveMessage 未传 id 时仍由 DB 生成 nanoid', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('r2s', 'r2', 1, 1)`,
).run();
const saved = sessionService.saveMessage({
sessionId: 'r2s',
role: 'user',
content: 'DB 生成 id',
});
expect(saved.id).toMatch(/^msg_/);
});
it('updateMessageContent 按透传 id 可命中(R1 完整链路)', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('r3s', 'r3', 1, 1)`,
).run();
sessionService.saveMessage({
sessionId: 'r3s',
role: 'user',
content: '原始内容',
id: 'edit-target-id',
});
const updated = sessionService.updateMessageContent('r3s', 'edit-target-id', '编辑后');
expect(updated).toBe(true);
const row = db.prepare(`SELECT content FROM messages WHERE id = 'edit-target-id'`).get() as {
content: string;
};
expect(row.content).toBe('编辑后');
});
});