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 跳过
1038 lines
41 KiB
TypeScript
1038 lines
41 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,
|
||
deleted_at INTEGER,
|
||
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-gram)LIKE 回退
|
||
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,
|
||
deleted_at INTEGER,
|
||
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('truncateMessagesAfter:inclusive=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('truncateMessagesAfter:inclusive 变体对摘要游标的联动(游标落在删除区则删摘要)', () => {
|
||
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、c(rowid >= 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 清空消息、重置计数并置空 metadata(B-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,
|
||
deleted_at INTEGER,
|
||
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('回收站会话(deleted_at 非空)从搜索结果整体剔除(v0.8.0 P2-1 契约)', () => {
|
||
// v0.8.0 P2-1 契约变更: 旧实现把缺标题的会话显示为"(已删除会话)"占位;
|
||
// 软删除引入后,回收站会话的搜索命中整体剔除(恢复后重新可见),
|
||
// 硬删除会话的消息已级联清除,占位分支不再可达。
|
||
insertMessage('q9_m1', 'q9_ghost', 'user', 'ghostkeyword 消息', 1);
|
||
db.prepare(
|
||
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q9_ghost', 'ghost', 1, 1)`,
|
||
).run();
|
||
const before = sessionService.searchMessages('ghostkeyword');
|
||
expect(before.some((r: any) => r.sessionId === 'q9_ghost')).toBe(true);
|
||
|
||
// 软删除后 → 搜索不再命中
|
||
db.prepare(`UPDATE sessions SET deleted_at = ? WHERE id = 'q9_ghost'`).run(Date.now());
|
||
const after = sessionService.searchMessages('ghostkeyword');
|
||
expect(after.some((r: any) => r.sessionId === 'q9_ghost')).toBe(false);
|
||
|
||
// 恢复后 → 重新命中
|
||
db.prepare(`UPDATE sessions SET deleted_at = NULL WHERE id = 'q9_ghost'`).run();
|
||
const restored = sessionService.searchMessages('ghostkeyword');
|
||
expect(restored.some((r: any) => r.sessionId === 'q9_ghost')).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 透传与 updateMessageContent(R1 回归)', () => {
|
||
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,
|
||
deleted_at INTEGER,
|
||
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('编辑后');
|
||
});
|
||
});
|