Files
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
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 跳过
2026-09-05 20:06:26 +08:00

1038 lines
41 KiB
TypeScript
Raw Permalink 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,
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-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,
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('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,
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 透传与 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,
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('编辑后');
});
});