feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

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)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
@@ -57,12 +57,18 @@ describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => {
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
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);
@@ -149,6 +155,41 @@ describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => {
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);
@@ -223,6 +264,570 @@ describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => {
});
});
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;
@@ -323,3 +928,91 @@ describe.skipIf(!dbAvailable)('审计日志导出(v0.5.0', () => {
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('编辑后');
});
});