feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* AuditService 单元测试(P1-14 测试基线)
|
||||
* 覆盖:链式哈希生成与验证、篡改检测
|
||||
* AuditService 单元测试(P1-14 测试基线扩充)
|
||||
* 覆盖:链式哈希生成与验证、篡改检测、resetChainCache、query 过滤矩阵、
|
||||
* CSV/JSONL 导出转义、logToolCall/logLLMRequest/logSessionEnd 等便捷方法。
|
||||
*
|
||||
* 注意:better-sqlite3 为原生模块——若本机安装的构建与系统 Node ABI 不匹配,
|
||||
* 测试自动跳过(skipIf),避免误报失败。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import log from 'electron-log';
|
||||
|
||||
// better-sqlite3 加载失败时(ABI 不匹配)自动跳过整个套件
|
||||
@@ -32,14 +34,12 @@ log.transports.file.level = false;
|
||||
|
||||
describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
|
||||
let dir: string;
|
||||
// eslint-disable 是失效指令(测试文件 overrides 已关闭 no-explicit-any),已移除
|
||||
let db: any;
|
||||
let service: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { AuditService } = await import('../../services/audit.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-audit-'));
|
||||
// 手动建库(绕过 DatabaseService 的 electron 依赖)
|
||||
db = new Database(join(dir, 'test.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
@@ -65,7 +65,11 @@ describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { db?.close(); } catch { /* ignore */ }
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -103,6 +107,8 @@ describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
|
||||
const result = service.verifyChain();
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.totalRecords).toBe(2);
|
||||
expect(result.tamperedId).toBeNull();
|
||||
expect(result.verifiedRecords).toBe(2);
|
||||
});
|
||||
|
||||
it('篡改记录后 verifyChain 检测到 tampered', () => {
|
||||
@@ -115,3 +121,663 @@ describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('AuditService — 链式哈希更多场景', () => {
|
||||
let dir: string;
|
||||
let db: any;
|
||||
let service: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { AuditService } = await import('../../services/audit.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-audit2-'));
|
||||
db = new Database(join(dir, 'test.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS 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 DEFAULT (unixepoch() * 1000),
|
||||
prev_hash TEXT,
|
||||
current_hash TEXT
|
||||
);
|
||||
`);
|
||||
service = new AuditService(() => db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
it('verifyChain 空表返回 valid 且 totalRecords=0', () => {
|
||||
const result = service.verifyChain();
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.totalRecords).toBe(0);
|
||||
expect(result.verifiedRecords).toBe(0);
|
||||
});
|
||||
|
||||
it('verifyChain 检测断链位置:中间记录被篡改时 tamperedId 指向该行', () => {
|
||||
// 写入 3 条
|
||||
for (let i = 0; i < 3; i++) {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: `t${i}`,
|
||||
outcome: 'error',
|
||||
});
|
||||
}
|
||||
// 篡改第 2 条的 target
|
||||
db.prepare(`UPDATE audit_logs SET target = 'hacked' WHERE id = 2`).run();
|
||||
const result = service.verifyChain();
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.tamperedId).toBe(2);
|
||||
// 只有第 1 条被验证通过
|
||||
expect(result.verifiedRecords).toBe(1);
|
||||
});
|
||||
|
||||
it('verifyChain 检测到首条被篡改(tamperedId=1)', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 't0',
|
||||
outcome: 'error',
|
||||
});
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 't1',
|
||||
outcome: 'error',
|
||||
});
|
||||
db.prepare(`UPDATE audit_logs SET session_id = 'x' WHERE id = 1`).run();
|
||||
const result = service.verifyChain();
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.tamperedId).toBe(1);
|
||||
});
|
||||
|
||||
it('verifyChain 兼容旧算法(JSON.stringify 插入序生成的 legacy hash)', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'legacy',
|
||||
outcome: 'error',
|
||||
details: { b: 1, a: 2 },
|
||||
});
|
||||
const row = db.prepare('SELECT * FROM audit_logs WHERE id = 1').get();
|
||||
// 旧算法:JSON.stringify 按字段插入序(与 computeHash 传入顺序一致),
|
||||
// 对象 key 不排序 → 对含嵌套 details 的行与 stableStringify 结果不同。
|
||||
const legacyContent = JSON.stringify({
|
||||
prevHash: row.prev_hash,
|
||||
sessionId: row.session_id,
|
||||
iteration: row.iteration,
|
||||
eventType: row.event_type,
|
||||
actor: row.actor,
|
||||
target: row.target,
|
||||
details: row.details, // 已是序列化字符串
|
||||
outcome: row.outcome,
|
||||
durationMs: row.duration_ms,
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex');
|
||||
|
||||
// 用旧算法 hash 覆盖 current_hash,模拟 #36 之前落盘的旧数据
|
||||
db.prepare('UPDATE audit_logs SET current_hash = ? WHERE id = 1').run(legacyHash);
|
||||
// 若 legacy 兼容分支未实现 → 判定 tampered;实现则 valid
|
||||
const result = service.verifyChain();
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.tamperedId).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyChain 对新数据仍用 stableStringify 校验(旧算法 hash 无法通过新校验)', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'modern',
|
||||
outcome: 'error',
|
||||
details: { b: 1, a: 2 },
|
||||
});
|
||||
const row = db.prepare('SELECT * FROM audit_logs WHERE id = 1').get();
|
||||
const legacyContent = JSON.stringify({
|
||||
prevHash: row.prev_hash,
|
||||
sessionId: row.session_id,
|
||||
iteration: row.iteration,
|
||||
eventType: row.event_type,
|
||||
actor: row.actor,
|
||||
target: row.target,
|
||||
details: row.details,
|
||||
outcome: row.outcome,
|
||||
durationMs: row.duration_ms,
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex');
|
||||
// 旧算法 hash 与 stable hash 不同(details 含多 key 对象时 key 顺序差异)
|
||||
expect(legacyHash).not.toBe(row.current_hash);
|
||||
// 但旧算法 hash 对当前行可被 legacy 分支接受 → verifyChain 仍 valid。
|
||||
// 该分支的存在是设计意图(兼容旧数据),此处锁定"两算法结果不同"的区分度。
|
||||
expect(row.current_hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(service.verifyChain().valid).toBe(true);
|
||||
});
|
||||
|
||||
it('verifyChain 跳过 current_hash 为 NULL 的旧行(未回填哈希)', () => {
|
||||
db.prepare(
|
||||
`INSERT INTO audit_logs (session_id, event_type, actor, target, created_at, prev_hash, current_hash)
|
||||
VALUES ('s', 'error', 'agent', 'old-no-hash', 1, NULL, NULL)`,
|
||||
).run();
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'new-hash',
|
||||
outcome: 'error',
|
||||
});
|
||||
const result = service.verifyChain();
|
||||
expect(result.valid).toBe(true);
|
||||
// 新行验证通过 1 条
|
||||
expect(result.verifiedRecords).toBe(1);
|
||||
});
|
||||
|
||||
it('resetChainCache 后首条 prev_hash 回到零哈希(P1-5 修复)', () => {
|
||||
// 写两条产生链
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'a',
|
||||
outcome: 'error',
|
||||
});
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'b',
|
||||
outcome: 'error',
|
||||
});
|
||||
|
||||
// 清空表(测试环境无 INSERT-ONLY 触发器)
|
||||
db.prepare('DELETE FROM audit_logs').run();
|
||||
service.resetChainCache();
|
||||
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'first-after-reset',
|
||||
outcome: 'error',
|
||||
});
|
||||
const row = db.prepare('SELECT prev_hash FROM audit_logs').get();
|
||||
expect(row.prev_hash).toBe('0'.repeat(64));
|
||||
expect(service.verifyChain().valid).toBe(true);
|
||||
});
|
||||
|
||||
it('未 resetChainCache 时清空表后新首条 prev_hash 引用旧哈希(P1-5 缺陷复现语义)', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'old',
|
||||
outcome: 'error',
|
||||
});
|
||||
const oldHash = (
|
||||
db.prepare('SELECT current_hash FROM audit_logs').get() as { current_hash: string }
|
||||
).current_hash;
|
||||
|
||||
db.prepare('DELETE FROM audit_logs').run();
|
||||
// 不调用 resetChainCache —— 缓存仍持有 oldHash
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'new',
|
||||
outcome: 'error',
|
||||
});
|
||||
const row = db.prepare('SELECT prev_hash FROM audit_logs').get();
|
||||
expect(row.prev_hash).toBe(oldHash); // 缺陷行为:引用已删除行的 hash
|
||||
// 验证链因此断裂
|
||||
expect(service.verifyChain().valid).toBe(false);
|
||||
});
|
||||
|
||||
it('current_hash 是 64 位十六进制 SHA-256', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'hash-format',
|
||||
outcome: 'error',
|
||||
});
|
||||
const row = db.prepare('SELECT current_hash FROM audit_logs').get();
|
||||
expect(row.current_hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('相同 details 对象不同 key 顺序产生相同 hash(#36 stableStringify 契约)', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'stable',
|
||||
details: { b: 1, a: 2 },
|
||||
outcome: 'error',
|
||||
});
|
||||
const row = db.prepare('SELECT current_hash FROM audit_logs').get();
|
||||
// stableStringify 排序 key 后与显式构造的 {a:2,b:1} 一致
|
||||
expect(row.current_hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
// 再写一条同语义不同顺序的 → hash 与仅按顺序不同无法直接对比(created_at 参与),
|
||||
// 但确认不抛错且链验证通过
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'stable2',
|
||||
details: { a: 2, b: 1 },
|
||||
outcome: 'error',
|
||||
});
|
||||
expect(service.verifyChain().valid).toBe(true);
|
||||
});
|
||||
|
||||
it('log 写入 details JSON 字符串(null 存 NULL)', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'details-null',
|
||||
});
|
||||
const row = db.prepare('SELECT details FROM audit_logs').get();
|
||||
expect(row.details).toBeNull();
|
||||
});
|
||||
|
||||
it('log 失败不抛错(写库异常时静默记录 WARN)', () => {
|
||||
// 关闭连接后写入 → prepare 抛错 → log 捕获
|
||||
const closed = new Database(':memory:');
|
||||
closed.close();
|
||||
const broken = new service.constructor(() => closed);
|
||||
expect(() =>
|
||||
broken.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'boom',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('logToolCall 写入 tool_call 事件且 long result 截断 1000 字符', () => {
|
||||
service.logToolCall({
|
||||
sessionId: 's',
|
||||
iteration: 1,
|
||||
toolName: 'read_file',
|
||||
args: { path: '/a' },
|
||||
outcome: 'success',
|
||||
result: 'r'.repeat(2000),
|
||||
durationMs: 5,
|
||||
});
|
||||
const row = db.prepare('SELECT * FROM audit_logs').get();
|
||||
expect(row.event_type).toBe('tool_call');
|
||||
expect(row.target).toBe('read_file');
|
||||
expect(row.iteration).toBe(1);
|
||||
const details = JSON.parse(row.details);
|
||||
expect(details.args).toEqual({ path: '/a' });
|
||||
expect(details.result).toHaveLength(1000);
|
||||
expect(row.outcome).toBe('success');
|
||||
expect(row.duration_ms).toBe(5);
|
||||
});
|
||||
|
||||
it('logLLMRequest 写入 llm_request 事件(target=provider/model)', () => {
|
||||
service.logLLMRequest({
|
||||
sessionId: 's',
|
||||
iteration: 2,
|
||||
provider: 'deepseek',
|
||||
model: 'v4',
|
||||
tokenCount: 1000,
|
||||
});
|
||||
const row = db.prepare('SELECT * FROM audit_logs').get();
|
||||
expect(row.event_type).toBe('llm_request');
|
||||
expect(row.target).toBe('deepseek/v4');
|
||||
expect(JSON.parse(row.details)).toEqual({ tokenCount: 1000 });
|
||||
});
|
||||
|
||||
it('logLLMResponse 写入 llm_response 事件含 tokenUsage 与 outcome=success', () => {
|
||||
service.logLLMResponse({
|
||||
sessionId: 's',
|
||||
iteration: 2,
|
||||
provider: 'deepseek',
|
||||
model: 'v4',
|
||||
tokenUsage: { input: 10, output: 20, total: 30 },
|
||||
durationMs: 42,
|
||||
});
|
||||
const row = db.prepare('SELECT * FROM audit_logs').get();
|
||||
expect(row.event_type).toBe('llm_response');
|
||||
expect(row.outcome).toBe('success');
|
||||
expect(row.duration_ms).toBe(42);
|
||||
expect(JSON.parse(row.details)).toEqual({ tokenUsage: { input: 10, output: 20, total: 30 } });
|
||||
});
|
||||
|
||||
it('logSessionStart 写入 session_start 事件(actor=user)', () => {
|
||||
service.logSessionStart('sess-1');
|
||||
const row = db.prepare('SELECT * FROM audit_logs').get();
|
||||
expect(row.event_type).toBe('session_start');
|
||||
expect(row.actor).toBe('user');
|
||||
expect(row.target).toBe('session');
|
||||
expect(row.session_id).toBe('sess-1');
|
||||
});
|
||||
|
||||
it('logSessionEnd 写入 session_end 事件(details 含迭代/token/终止原因)', () => {
|
||||
service.logSessionEnd({
|
||||
sessionId: 's',
|
||||
totalIterations: 5,
|
||||
totalTokens: 999,
|
||||
durationMs: 60000,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
const row = db.prepare('SELECT * FROM audit_logs').get();
|
||||
expect(row.event_type).toBe('session_end');
|
||||
expect(row.outcome).toBe('success');
|
||||
expect(JSON.parse(row.details)).toEqual({
|
||||
totalIterations: 5,
|
||||
totalTokens: 999,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('AuditService — query 过滤矩阵', () => {
|
||||
let dir: string;
|
||||
let db: any;
|
||||
let service: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { AuditService } = await import('../../services/audit.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-audit-query-'));
|
||||
db = new Database(join(dir, 'test.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
`);
|
||||
service = new AuditService(() => db);
|
||||
// 预置 4 条不同 session/type/actor 的日志
|
||||
const seed = [
|
||||
{ sessionId: 'sA', eventType: 'tool_call', actor: 'agent', target: 't1', created: 100 },
|
||||
{ sessionId: 'sA', eventType: 'error', actor: 'agent', target: 't2', created: 200 },
|
||||
{ sessionId: 'sB', eventType: 'tool_call', actor: 'user', target: 't3', created: 300 },
|
||||
{
|
||||
sessionId: 'sB',
|
||||
eventType: 'permission_check',
|
||||
actor: 'system',
|
||||
target: 't4',
|
||||
created: 400,
|
||||
},
|
||||
];
|
||||
for (const e of seed) {
|
||||
service.log({
|
||||
sessionId: e.sessionId,
|
||||
eventType: e.eventType,
|
||||
actor: e.actor,
|
||||
target: e.target,
|
||||
});
|
||||
db.prepare(
|
||||
'UPDATE audit_logs SET created_at = ? WHERE id = (SELECT MAX(id) FROM audit_logs)',
|
||||
).run(e.created);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
it('无过滤返回全部记录且按 created_at 倒序', () => {
|
||||
const rows = service.query();
|
||||
expect(rows).toHaveLength(4);
|
||||
// created_at 倒序:400,300,200,100
|
||||
expect(rows[0].target).toBe('t4');
|
||||
expect(rows[3].target).toBe('t1');
|
||||
});
|
||||
|
||||
it('sessionId 过滤只返回该会话', () => {
|
||||
const rows = service.query({ sessionId: 'sA' });
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.every((r: any) => r.session_id === 'sA')).toBe(true);
|
||||
});
|
||||
|
||||
it('eventType 过滤只返回该事件类型', () => {
|
||||
const rows = service.query({ eventType: 'tool_call' });
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.every((r: any) => r.event_type === 'tool_call')).toBe(true);
|
||||
});
|
||||
|
||||
it('sessionId + eventType 组合过滤', () => {
|
||||
const rows = service.query({ sessionId: 'sB', eventType: 'tool_call' });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].target).toBe('t3');
|
||||
});
|
||||
|
||||
it('limit 限制返回条数(按时间倒序取前 N)', () => {
|
||||
const rows = service.query({ limit: 2 });
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].target).toBe('t4');
|
||||
expect(rows[1].target).toBe('t3');
|
||||
});
|
||||
|
||||
it('limit 与过滤组合生效', () => {
|
||||
const rows = service.query({ sessionId: 'sB', limit: 1 });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].target).toBe('t4');
|
||||
});
|
||||
|
||||
it('actor 字段随行返回(query 无 actor 过滤参数 —— 按 actor 由调用方筛选)', () => {
|
||||
const rows = service.query();
|
||||
const actors = rows.map((r: any) => r.actor);
|
||||
expect(actors).toContain('user');
|
||||
expect(actors).toContain('system');
|
||||
expect(actors).toContain('agent');
|
||||
});
|
||||
|
||||
it('不存在的 sessionId 过滤返回空数组', () => {
|
||||
expect(service.query({ sessionId: 'ghost' })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('AuditService — 导出与脱敏', () => {
|
||||
let dir: string;
|
||||
let db: any;
|
||||
let service: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { AuditService } = await import('../../services/audit.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-audit-export2-'));
|
||||
db = new Database(join(dir, 'test.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
`);
|
||||
service = new AuditService(() => db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
it('exportJSONL 空表返回空串', () => {
|
||||
expect(service.exportJSONL()).toBe('');
|
||||
});
|
||||
|
||||
it('exportJSONL 每行一条 JSON 且末尾换行', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 't',
|
||||
outcome: 'error',
|
||||
});
|
||||
const jsonl = service.exportJSONL();
|
||||
expect(jsonl.endsWith('\n')).toBe(true);
|
||||
const lines = jsonl.trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
const parsed = JSON.parse(lines[0]);
|
||||
expect(parsed.session_id).toBe('s');
|
||||
expect(parsed.current_hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('exportCSV 空表仅表头', () => {
|
||||
const csv = service.exportCSV();
|
||||
const lines = csv.split('\n').filter((l: string) => l.length > 0);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain('id,session_id,iteration,event_type');
|
||||
});
|
||||
|
||||
it('CSV 转义:逗号字段被双引号包裹', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'a,b,c',
|
||||
outcome: 'error',
|
||||
});
|
||||
const csv = service.exportCSV();
|
||||
expect(csv).toContain('"a,b,c"');
|
||||
});
|
||||
|
||||
it('CSV 转义:字段内双引号翻倍(RFC 4180)', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'say "hi"',
|
||||
outcome: 'error',
|
||||
});
|
||||
const csv = service.exportCSV();
|
||||
expect(csv).toContain('"say ""hi"""');
|
||||
});
|
||||
|
||||
it('CSV 转义:字段内换行保留且整字段双引号包裹(RFC 4180 多行字段)', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'line1\nline2',
|
||||
outcome: 'error',
|
||||
});
|
||||
const csv = service.exportCSV();
|
||||
// 换行在双引号字段内 → 物理行 = 表头 + 记录(跨 2 行)+ 末尾空行(尾部 \n)
|
||||
expect(csv.split('\n')).toHaveLength(4);
|
||||
// 引号包裹的字段内保留字面换行
|
||||
expect(csv).toContain('"line1\nline2"');
|
||||
});
|
||||
|
||||
it('CSV 数值与空值字段不包裹', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'plain',
|
||||
outcome: 'error',
|
||||
durationMs: 123,
|
||||
});
|
||||
const csv = service.exportCSV();
|
||||
const row = csv.split('\n')[1];
|
||||
// duration_ms 字段为纯数字不被引号包裹
|
||||
expect(row).toContain(',123,');
|
||||
// outcome 字段 plain 不带引号
|
||||
expect(csv).toContain(',plain,');
|
||||
});
|
||||
|
||||
it('exportJSONL 与 exportCSV 记录数一致', () => {
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 't',
|
||||
outcome: 'error',
|
||||
});
|
||||
service.log({
|
||||
sessionId: 's',
|
||||
eventType: 'error',
|
||||
actor: 'agent',
|
||||
target: 'u',
|
||||
outcome: 'error',
|
||||
});
|
||||
const jsonlCount = service.exportJSONL().trim().split('\n').length;
|
||||
const csvCount =
|
||||
service
|
||||
.exportCSV()
|
||||
.split('\n')
|
||||
.filter((l: string) => l.length > 0).length - 1;
|
||||
expect(jsonlCount).toBe(csvCount);
|
||||
});
|
||||
|
||||
it('CSV 表头字段序固定(导出 schema 稳定性)', () => {
|
||||
service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 't' });
|
||||
const header = service.exportCSV().split('\n')[0];
|
||||
expect(header).toBe(
|
||||
'id,session_id,iteration,event_type,actor,target,details,outcome,duration_ms,created_at,prev_hash,current_hash',
|
||||
);
|
||||
});
|
||||
|
||||
it('JSONL 行含 prev_hash 与 current_hash 完整链字段', () => {
|
||||
service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'a' });
|
||||
service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'b' });
|
||||
const parsed = service
|
||||
.exportJSONL()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l: string) => JSON.parse(l));
|
||||
expect(parsed[1].prev_hash).toBe(parsed[0].current_hash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -255,3 +255,304 @@ describe.skipIf(!dbAvailable)('敏感 key — 全局层密文落盘', () => {
|
||||
expect(rawFile).toContain('metona-enc:v1:');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('isUnconfiguredGlobalKey — 边界值矩阵', () => {
|
||||
it.each([
|
||||
// [key, value, expected]
|
||||
['llm.apiKey', '', true],
|
||||
['llm.apiKey', null, true],
|
||||
['llm.apiKey', undefined, true],
|
||||
['llm.apiKey', 0, false], // 数字 0 非空串非默认值
|
||||
['llm.model', '', true],
|
||||
// 默认值 → 未配置
|
||||
['agent.maxIterations', 20, true],
|
||||
['agent.totalTimeoutMs', 600000, true],
|
||||
['agent.enableThinking', true, true],
|
||||
['llm.maxTokens', 63488, true],
|
||||
['onboarding.completed', false, true],
|
||||
['ui.theme', 'auto', true],
|
||||
// 非默认值 → 已配置
|
||||
['agent.maxIterations', 21, false],
|
||||
['agent.totalTimeoutMs', 1, false],
|
||||
['agent.enableThinking', false, false], // 用户主动关闭
|
||||
['llm.maxTokens', 1000, false],
|
||||
['onboarding.completed', true, false], // 用户已完成引导
|
||||
['ui.theme', 'dark', false],
|
||||
// 非全局 key 恒未配置判定(不被 SEED_DEFAULTS 覆盖)
|
||||
['searxng.enabled', 'anything', false],
|
||||
['tools.write_file.enabled', false, false],
|
||||
])('isUnconfiguredGlobalKey(%s, %j) → %j', (key, value, expected) => {
|
||||
expect(isUnconfiguredGlobalKey(key, value)).toBe(expected);
|
||||
});
|
||||
|
||||
it('isGlobalKey 边界:完整前缀匹配而非子串(deepseek.contextWindow 是全局 key)', () => {
|
||||
expect(isGlobalKey('deepseek.contextWindow')).toBe(true);
|
||||
expect(isGlobalKey('deepseek.apiKey')).toBe(true);
|
||||
expect(isGlobalKey('memory.consolidationEnabled')).toBe(true);
|
||||
expect(isGlobalKey('memory.custom')).toBe(true);
|
||||
// 非全局前缀
|
||||
expect(isGlobalKey('workspace.path')).toBe(false);
|
||||
expect(isGlobalKey('searxng.apiKey')).toBe(false);
|
||||
expect(isGlobalKey('mcp.autoReconnect')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('ConfigService.setBatch — 批量写入', () => {
|
||||
it('setBatch 多 key 一次写入 DB 与全局层(全局 key 双写)', () => {
|
||||
configService.setBatch([
|
||||
{ key: 'llm.model', value: 'mimo-v3' },
|
||||
{ key: 'agent.maxIterations', value: 40 },
|
||||
{ key: 'ui.theme', value: 'light' },
|
||||
]);
|
||||
expect(configService.get<string>('llm.model')).toBe('mimo-v3');
|
||||
expect(configService.get<number>('agent.maxIterations')).toBe(40);
|
||||
expect(configService.get<string>('ui.theme')).toBe('light');
|
||||
// 全局层同步
|
||||
expect(globalConfig.get<string>('llm.model')).toBe('mimo-v3');
|
||||
expect(globalConfig.get<number>('agent.maxIterations')).toBe(40);
|
||||
});
|
||||
|
||||
it('setBatch 敏感 key 加密落盘、读取解密', () => {
|
||||
configService.setBatch([{ key: 'llm.apiKey', value: 'sk-batch-secret' }]);
|
||||
const raw = db!.prepare("SELECT value FROM app_config WHERE key = 'llm.apiKey'").get() as {
|
||||
value: string;
|
||||
};
|
||||
expect(raw.value).toContain('metona-enc:v1:');
|
||||
expect(configService.get<string>('llm.apiKey')).toBe('sk-batch-secret');
|
||||
expect(globalConfig.get<string>('llm.apiKey')).toBe('sk-batch-secret');
|
||||
});
|
||||
|
||||
it('setBatch 保留已有 category(与 set 语义一致)', () => {
|
||||
db!
|
||||
.prepare(
|
||||
"INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '\"blue\"', 'ui')",
|
||||
)
|
||||
.run();
|
||||
configService.setBatch([{ key: 'ui.theme', value: 'dark' }]);
|
||||
const row = db!.prepare("SELECT category FROM app_config WHERE key = 'ui.theme'").get() as {
|
||||
category: string;
|
||||
};
|
||||
expect(row.category).toBe('ui');
|
||||
});
|
||||
|
||||
it('setBatch 非全局 key 不写全局层', () => {
|
||||
configService.setBatch([{ key: 'workspace.localOnly', value: 'x' }]);
|
||||
expect(globalConfig.get<string>('workspace.localOnly')).toBeNull();
|
||||
});
|
||||
|
||||
it('setBatch 空数组不抛错且无副作用', () => {
|
||||
expect(() => configService.setBatch([])).not.toThrow();
|
||||
expect(globalConfig.getAll()).toEqual({});
|
||||
});
|
||||
|
||||
it('setBatch 部分失败(JSON.stringify 抛错)→ DB 事务整体回滚,不产生部分写入', () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular; // 无法 JSON 序列化
|
||||
expect(() =>
|
||||
configService.setBatch([
|
||||
{ key: 'ui.theme', value: 'dark' },
|
||||
{ key: 'agent.maxIterations', value: circular },
|
||||
]),
|
||||
).toThrow();
|
||||
// 事务回滚:第一个 key 也不应残留
|
||||
const row = db!.prepare("SELECT value FROM app_config WHERE key = 'ui.theme'").get();
|
||||
expect(row).toBeUndefined();
|
||||
// 全局层不被触碰(事务异常中断在 DB 阶段)
|
||||
expect(globalConfig.get<string>('ui.theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('setBatch 更新 updated_at 时间戳', () => {
|
||||
configService.setBatch([{ key: 'llm.model', value: 'a' }]);
|
||||
const row = db!.prepare("SELECT updated_at FROM app_config WHERE key = 'llm.model'").get() as {
|
||||
updated_at: number;
|
||||
};
|
||||
expect(row.updated_at).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('ConfigService — getByCategory / delete / 值类型', () => {
|
||||
it('getByCategory 只返回该分类下的 key', () => {
|
||||
// 直接按真实库的 category 灌入(set 默认 category=general,需先有分类)
|
||||
configService.set('ui.theme', 'dark');
|
||||
configService.set('llm.provider', 'deepseek');
|
||||
configService.set('ui.fontSize', 14);
|
||||
db!
|
||||
.prepare("UPDATE app_config SET category = 'ui' WHERE key IN ('ui.theme', 'ui.fontSize')")
|
||||
.run();
|
||||
|
||||
const ui = configService.getByCategory('ui');
|
||||
expect(Object.keys(ui).sort()).toEqual(['ui.fontSize', 'ui.theme']);
|
||||
expect(ui['ui.theme']).toBe('dark');
|
||||
expect(ui['ui.fontSize']).toBe(14);
|
||||
expect('llm.provider' in ui).toBe(false);
|
||||
});
|
||||
|
||||
it('getByCategory 不存在的分类返回空对象', () => {
|
||||
expect(configService.getByCategory('nonexistent')).toEqual({});
|
||||
});
|
||||
|
||||
it('getByCategory 返回的敏感 key 值为密文(getByCategory 未做解密 —— 实际行为契约)', () => {
|
||||
db!
|
||||
.prepare("INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"\"', 'llm')")
|
||||
.run();
|
||||
configService.set('llm.apiKey', 'sk-cat-secret');
|
||||
const llm = configService.getByCategory('llm');
|
||||
expect(llm['llm.apiKey']).toContain('metona-enc:v1:');
|
||||
expect(llm['llm.apiKey']).not.toContain('sk-cat-secret');
|
||||
// 对照:get() 仍解密
|
||||
expect(configService.get<string>('llm.apiKey')).toBe('sk-cat-secret');
|
||||
});
|
||||
|
||||
it('getByCategory 容忍损坏 JSON(原样字符串返回)', () => {
|
||||
db!
|
||||
.prepare("INSERT INTO app_config (key, value, category) VALUES ('ui.raw', '{bad-json', 'ui')")
|
||||
.run();
|
||||
const ui = configService.getByCategory('ui');
|
||||
expect(ui['ui.raw']).toBe('{bad-json');
|
||||
});
|
||||
|
||||
it('delete 非全局 key:删除后 get 返回 null(无全局回退)', () => {
|
||||
configService.set('workspace.localFlag', 'x');
|
||||
expect(configService.delete('workspace.localFlag')).toBe(true);
|
||||
expect(configService.get('workspace.localFlag')).toBeNull();
|
||||
expect(configService.delete('workspace.localFlag')).toBe(false);
|
||||
expect(configService.delete('never-existed')).toBe(false);
|
||||
});
|
||||
|
||||
it('delete 全局 key:DB 行删除但 get 经全局层回退仍可读(双写不联动删除)', () => {
|
||||
configService.set('llm.model', 'mimo');
|
||||
expect(configService.delete('llm.model')).toBe(true);
|
||||
const row = db!.prepare("SELECT value FROM app_config WHERE key = 'llm.model'").get();
|
||||
expect(row).toBeUndefined();
|
||||
// 全局层保留 → get 回退命中
|
||||
expect(globalConfig.get<string>('llm.model')).toBe('mimo');
|
||||
expect(configService.get<string>('llm.model')).toBe('mimo');
|
||||
});
|
||||
|
||||
it('set/get 支持对象与数组值(JSON 序列化语义)', () => {
|
||||
configService.set('ui.pinnedSessions', ['a', 'b']);
|
||||
configService.set('agent.metadata', { enabled: true, max: 3 });
|
||||
expect(configService.get<Array<string>>('ui.pinnedSessions')).toEqual(['a', 'b']);
|
||||
expect(configService.get<{ enabled: boolean; max: number }>('agent.metadata')).toEqual({
|
||||
enabled: true,
|
||||
max: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('set 布尔与 null 值可回读', () => {
|
||||
configService.set('ui.flag', false);
|
||||
configService.set('ollama.numCtx', null);
|
||||
expect(configService.get<boolean>('ui.flag')).toBe(false);
|
||||
expect(configService.get<null>('ollama.numCtx')).toBeNull();
|
||||
});
|
||||
|
||||
it('历史明文敏感 key 平滑兼容:DB 存明文时 get 原样返回', () => {
|
||||
// 模拟旧版本未加密落盘的 apiKey
|
||||
db!
|
||||
.prepare(
|
||||
"INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"sk-legacy-plain\"', 'llm')",
|
||||
)
|
||||
.run();
|
||||
expect(configService.get<string>('llm.apiKey')).toBe('sk-legacy-plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('migrateFromWorkspaceDB — 边界矩阵', () => {
|
||||
it('false 值且默认值非 false → 迁移(用户主动关闭)', () => {
|
||||
const migrated = globalConfig.migrateFromWorkspaceDB({ 'agent.enableThinking': false });
|
||||
expect(migrated).toBe(1);
|
||||
expect(globalConfig.get<boolean>('agent.enableThinking')).toBe(false);
|
||||
});
|
||||
|
||||
it('false 值且默认值即 false → 跳过(seedDefaults 灌入)', () => {
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'onboarding.completed': false })).toBe(0);
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.multimodalEnabled': false })).toBe(0);
|
||||
});
|
||||
|
||||
it('true 值且默认值 false → 迁移(用户主动开启)', () => {
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.multimodalEnabled': true })).toBe(1);
|
||||
expect(globalConfig.get<boolean>('llm.multimodalEnabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('true 值且默认值 true → 跳过', () => {
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'security.promptInjectionDefense': true })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'agent.enableThinking': true })).toBe(0);
|
||||
});
|
||||
|
||||
it('0 与空对象等非空非默认值 → 迁移', () => {
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.temperature': 0 })).toBe(0); // 默认 0 → 跳过
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.temperature': 0.5 })).toBe(1);
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'ui.customFlag': 'on' })).toBe(1);
|
||||
});
|
||||
|
||||
it('敏感 key 迁移:全局 JSON 中为密文(无明文泄漏)', () => {
|
||||
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.apiKey': 'sk-mig-secret' });
|
||||
expect(migrated).toBe(1);
|
||||
expect(globalConfig.get<string>('llm.apiKey')).toBe('sk-mig-secret');
|
||||
const rawFile = readFileSync(join(mockState.userDataDir, 'global-config.json'), 'utf-8');
|
||||
expect(rawFile).not.toContain('sk-mig-secret');
|
||||
expect(rawFile).toContain('metona-enc:v1:');
|
||||
});
|
||||
|
||||
it('数值边界:默认值 float 精确相等判定', () => {
|
||||
// deepseek.contextWindow 默认 1000000
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'deepseek.contextWindow': 1000000 })).toBe(0);
|
||||
expect(globalConfig.migrateFromWorkspaceDB({ 'deepseek.contextWindow': 64000 })).toBe(1);
|
||||
});
|
||||
|
||||
it('迁移后新 ConfigService 读取(跨空间共享生效)', () => {
|
||||
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.provider': 'ollama' });
|
||||
expect(migrated).toBe(1);
|
||||
const db2 = makeDb();
|
||||
const freshConfig = new ConfigService(() => db2);
|
||||
freshConfig.setGlobalConfig(globalConfig);
|
||||
expect(freshConfig.get<string>('llm.provider')).toBe('ollama');
|
||||
db2.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('配置分层 — 空值回退细节', () => {
|
||||
it('DB 值等于 seed 默认值(0)时视为未配置 → 回退全局层真实值', () => {
|
||||
// llm.temperature seed 默认 0 —— DB 写入 0 与默认值不可区分 → 回退全局层
|
||||
db!
|
||||
.prepare(
|
||||
"INSERT INTO app_config (key, value, category) VALUES ('llm.temperature', '0', 'llm')",
|
||||
)
|
||||
.run();
|
||||
globalConfig.set('llm.temperature', 1);
|
||||
expect(configService.get<number>('llm.temperature')).toBe(1);
|
||||
});
|
||||
|
||||
it('DB 值为非默认数字 0(ui.theme 默认是字符串)→ 视为已配置不回退', () => {
|
||||
db!
|
||||
.prepare("INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '0', 'ui')")
|
||||
.run();
|
||||
globalConfig.set('ui.theme', 'dark');
|
||||
expect(configService.get<number>('ui.theme')).toBe(0);
|
||||
});
|
||||
|
||||
it('DB 有非空字符串但全局层为 null → 不覆盖为 null(返回 DB 值)', () => {
|
||||
configService.set('llm.model', 'local-model');
|
||||
// 全局层无该值
|
||||
expect(configService.get<string>('llm.model')).toBe('local-model');
|
||||
});
|
||||
|
||||
it('DB miss 且全局层为 null → get 返回 null', () => {
|
||||
expect(configService.get<string>('llm.provider')).toBeNull();
|
||||
});
|
||||
|
||||
it('getAll 合并时全局层未配置的 key 不进结果', () => {
|
||||
const all = configService.getAll();
|
||||
expect('llm.provider' in all).toBe(false);
|
||||
});
|
||||
|
||||
it('getAll 中 DB 未配置但全局层已配置 → 全局值补齐', () => {
|
||||
globalConfig.set('llm.provider', 'openai');
|
||||
configService.set('ui.theme', 'dark');
|
||||
const all = configService.getAll();
|
||||
expect(all['llm.provider']).toBe('openai');
|
||||
expect(all['ui.theme']).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -26,7 +26,16 @@ try {
|
||||
dbAvailable = false;
|
||||
}
|
||||
|
||||
import type { DatabaseService } from '../database.service';
|
||||
import { DatabaseService } from '../database.service';
|
||||
|
||||
/** 预置 legacy 库:先建 .metona 目录再开库(与 DatabaseService 目录布局一致) */
|
||||
function openLegacyDb(dir: string): any {
|
||||
const metonaDir = join(dir, '.metona');
|
||||
mkdirSync(metonaDir, { recursive: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const raw = require('better-sqlite3');
|
||||
return new raw(join(metonaDir, 'agent.db'));
|
||||
}
|
||||
|
||||
describe.skipIf(!dbAvailable)('DatabaseService — PRAGMA user_version 版本化', () => {
|
||||
let dir: string;
|
||||
@@ -55,7 +64,8 @@ describe.skipIf(!dbAvailable)('DatabaseService — PRAGMA user_version 版本化
|
||||
|
||||
it('initialize 后 user_version 已盖章为 SCHEMA_VERSION', () => {
|
||||
const version = svc2.getDB().pragma('user_version', { simple: true }) as number;
|
||||
expect(version).toBe(1);
|
||||
// v0.7.4 P4-4: SCHEMA_VERSION 1 → 2(迁移 9 trigram 纳入版本化)
|
||||
expect(version).toBe(DatabaseService.SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it('盖章库的探测批次被跳过(重复 initialize 不报错且配置 seed 保持存在)', () => {
|
||||
@@ -65,3 +75,338 @@ describe.skipIf(!dbAvailable)('DatabaseService — PRAGMA user_version 版本化
|
||||
expect(row.n).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('DatabaseService — 全量表结构与索引', () => {
|
||||
let dir: string;
|
||||
let svc: DatabaseService;
|
||||
let db: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-migtables-'));
|
||||
svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
db = svc.getDB();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
svc?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('messages 表包含 FTS 所需的全部列(content 可 NULL + attachments + reasoning_content)', () => {
|
||||
const cols = db.prepare('PRAGMA table_info(messages)').all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
}>;
|
||||
const names = cols.map((c) => c.name);
|
||||
for (const expected of [
|
||||
'id',
|
||||
'session_id',
|
||||
'role',
|
||||
'content',
|
||||
'reasoning_content',
|
||||
'tool_calls',
|
||||
'tool_result',
|
||||
'attachments',
|
||||
'iteration',
|
||||
'created_at',
|
||||
]) {
|
||||
expect(names).toContain(expected);
|
||||
}
|
||||
const contentCol = cols.find((c) => c.name === 'content');
|
||||
expect(contentCol!.notnull).toBe(0); // C-6: content 允许 NULL
|
||||
});
|
||||
|
||||
it('messages_fts 虚表使用 trigram tokenizer(迁移 9 目标 schema)', () => {
|
||||
const sqlRow = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { sql: string };
|
||||
expect(sqlRow.sql).toContain('trigram');
|
||||
});
|
||||
|
||||
it('FTS 触发器存活:insert/delete/update 三个触发器', () => {
|
||||
const triggers = db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='messages' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
const names = triggers.map((t) => t.name);
|
||||
expect(names).toContain('messages_fts_insert');
|
||||
expect(names).toContain('messages_fts_delete');
|
||||
expect(names).toContain('messages_fts_update');
|
||||
});
|
||||
|
||||
it('FTS 实时同步:插入消息后索引可见、删除后索引同步', () => {
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('mig_s1', 's', 1, 1)`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES ('mig_m1', 'mig_s1', 'user', 'migftshit 内容', 1)`,
|
||||
).run();
|
||||
const hit = db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'migftsh'`)
|
||||
.get() as { c: number };
|
||||
expect(hit.c).toBe(1);
|
||||
db.prepare(`DELETE FROM messages WHERE id = 'mig_m1'`).run();
|
||||
const miss = db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'migftsh'`)
|
||||
.get() as { c: number };
|
||||
expect(miss.c).toBe(0);
|
||||
});
|
||||
|
||||
it('audit_logs 的 INSERT-ONLY 触发器存活(audit_no_update / audit_no_delete)', () => {
|
||||
const triggers = db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='audit_logs' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
const names = triggers.map((t) => t.name);
|
||||
expect(names).toContain('audit_no_update');
|
||||
expect(names).toContain('audit_no_delete');
|
||||
// 更新被拒绝
|
||||
db.prepare(
|
||||
`INSERT INTO audit_logs (session_id, event_type, actor, target, created_at) VALUES ('s', 'error', 'agent', 't', 1)`,
|
||||
).run();
|
||||
expect(() =>
|
||||
db.prepare(`UPDATE audit_logs SET target = 'hacked' WHERE event_type = 'error'`).run(),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('episodic/semantic/working 记忆表均含 tf_cache 列(P2-12)', () => {
|
||||
for (const table of ['episodic_memories', 'semantic_memories', 'working_memories']) {
|
||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
expect(cols.map((c) => c.name)).toContain('tf_cache');
|
||||
}
|
||||
});
|
||||
|
||||
it('session_summaries 外键为 ON DELETE CASCADE(迁移 8)', () => {
|
||||
const fks = db.prepare('PRAGMA foreign_key_list(session_summaries)').all() as Array<{
|
||||
table: string;
|
||||
on_delete: string;
|
||||
}>;
|
||||
expect(fks.some((fk) => fk.table === 'sessions' && fk.on_delete === 'CASCADE')).toBe(true);
|
||||
});
|
||||
|
||||
it('mcp_servers transport CHECK 含 streamable-http(迁移 6)', () => {
|
||||
const sqlRow = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='mcp_servers'`)
|
||||
.get() as { sql: string };
|
||||
expect(sqlRow.sql).toContain('streamable-http');
|
||||
});
|
||||
|
||||
it('CONFIG_DEFAULTS 全部 seed 入库(每个默认 key 一行)', async () => {
|
||||
const { CONFIG_DEFAULTS } = await import('../database.service');
|
||||
const total = db.prepare('SELECT COUNT(*) AS c FROM app_config').get() as { c: number };
|
||||
expect(total.c).toBeGreaterThanOrEqual(CONFIG_DEFAULTS.length);
|
||||
// 抽查默认 key 存在
|
||||
const keys = db.prepare('SELECT key FROM app_config').all() as Array<{ key: string }>;
|
||||
const keySet = new Set(keys.map((k) => k.key));
|
||||
for (const d of CONFIG_DEFAULTS) {
|
||||
expect(keySet.has(d.key)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('索引覆盖常用查询路径(idx_messages_session 复合索引存在)', () => {
|
||||
const idx = db.prepare(`PRAGMA index_list(messages)`).all() as Array<{ name: string }>;
|
||||
expect(idx.map((i) => i.name)).toContain('idx_messages_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('DatabaseService — 多版本迁移矩阵(user_version 0/1/2)', () => {
|
||||
it('user_version=0 的旧库:初始化后完整迁移并盖章为 SCHEMA_VERSION', async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metona-migv0-'));
|
||||
// 预置一个"旧版"库:只有消息表 + 非 trigram FTS(模拟迁移 9 之前)
|
||||
const legacy = openLegacyDb(dir);
|
||||
legacy.exec(`
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||||
content TEXT NOT NULL, 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='unicode61');
|
||||
`);
|
||||
legacy.pragma('user_version = 0');
|
||||
legacy.close();
|
||||
|
||||
const svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
const db = svc.getDB();
|
||||
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
|
||||
// 迁移 9:FTS 重建为 trigram
|
||||
const ftsSql = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { sql: string };
|
||||
expect(ftsSql.sql).toContain('trigram');
|
||||
// 迁移 5:messages content 允许 NULL
|
||||
const cols = db.prepare('PRAGMA table_info(messages)').all() as Array<{
|
||||
notnull: number;
|
||||
name: string;
|
||||
}>;
|
||||
const contentCol = cols.find((c) => c.name === 'content');
|
||||
expect(contentCol!.notnull).toBe(0);
|
||||
svc.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('user_version=1 的库(迁移 9 之前盖章):重新初始化补齐 trigram', async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metona-migv1-'));
|
||||
const legacy = openLegacyDb(dir);
|
||||
legacy.exec(`
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||||
content TEXT NOT NULL, 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='unicode61');
|
||||
`);
|
||||
legacy.pragma('user_version = 1'); // 旧版本盖章但缺迁移 9
|
||||
legacy.close();
|
||||
|
||||
const svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
const db = svc.getDB();
|
||||
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
|
||||
const ftsSql = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { sql: string };
|
||||
expect(ftsSql.sql).toContain('trigram');
|
||||
svc.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('user_version=2 的库:快速路径跳过迁移,版本号保持不变', async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metona-migv2-'));
|
||||
const legacy = openLegacyDb(dir);
|
||||
legacy.exec(`
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||||
content TEXT, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='trigram');
|
||||
`);
|
||||
legacy.pragma('user_version = 2');
|
||||
legacy.close();
|
||||
|
||||
const svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
const db = svc.getDB();
|
||||
expect(db.pragma('user_version', { simple: true })).toBe(2);
|
||||
// 快速路径不重建 FTS(保持原有 trigram 表)
|
||||
const ftsSql = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { sql: string };
|
||||
expect(ftsSql.sql).toContain('trigram');
|
||||
svc.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('迁移 9 幂等:重复 initialize 不重复重建(SQL 始终为 trigram 单份定义)', async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metona-migidem-'));
|
||||
const svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
svc.close();
|
||||
const svc2 = new DatabaseService(dir);
|
||||
svc2.initialize();
|
||||
const db = svc2.getDB();
|
||||
const ftsRows = db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { c: number };
|
||||
expect(ftsRows.c).toBe(1);
|
||||
const sqlRow = db
|
||||
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
|
||||
.get() as { sql: string };
|
||||
expect(sqlRow.sql).toContain('trigram');
|
||||
svc2.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('迁移 7 FTS 回填:已有消息在 initialize 后进入索引(rebuild 语义)', async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metona-migbackfill-'));
|
||||
const legacy = openLegacyDb(dir);
|
||||
// 旧库:messages 有数据但 messages_fts 空(无回填)
|
||||
legacy.exec(`
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
|
||||
content TEXT NOT NULL, reasoning_content TEXT, tool_calls TEXT,
|
||||
tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL
|
||||
);
|
||||
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 VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='unicode61');
|
||||
`);
|
||||
legacy.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('s1', 1, 1)`).run();
|
||||
legacy
|
||||
.prepare(
|
||||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES ('m1', 's1', 'user', 'backfillkeyword 数据', 1)`,
|
||||
)
|
||||
.run();
|
||||
legacy.close();
|
||||
|
||||
const svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
const db = svc.getDB();
|
||||
// 迁移 9 重建 + rebuild 后,存量消息应可被搜索
|
||||
const hit = db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'backfillkeyw'`)
|
||||
.get() as { c: number };
|
||||
expect(hit.c).toBe(1);
|
||||
svc.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('DatabaseService — 种子配置与连接语义', () => {
|
||||
let dir: string;
|
||||
let svc: DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { DatabaseService } = await import('../database.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-migseed-'));
|
||||
svc = new DatabaseService(dir);
|
||||
svc.initialize();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
svc?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('敏感默认 key 以明文空串 seed(新用户未配置),category 正确', () => {
|
||||
const row = svc
|
||||
.getDB()
|
||||
.prepare(`SELECT value, category FROM app_config WHERE key = 'llm.apiKey'`)
|
||||
.get() as { value: string; category: string };
|
||||
expect(JSON.parse(row.value)).toBe('');
|
||||
expect(row.category).toBe('llm');
|
||||
});
|
||||
|
||||
it('数字/布尔默认值 seed 为 JSON 类型', () => {
|
||||
const row = svc
|
||||
.getDB()
|
||||
.prepare(`SELECT value FROM app_config WHERE key = 'agent.maxIterations'`)
|
||||
.get() as { value: string };
|
||||
expect(JSON.parse(row.value)).toBe(20);
|
||||
});
|
||||
|
||||
it('WAL 模式已开启(journal_mode=wal)', () => {
|
||||
const mode = svc.getDB().pragma('journal_mode', { simple: true }) as string;
|
||||
expect(mode).toBe('wal');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,21 +18,12 @@ import {
|
||||
safeParseHeaders,
|
||||
validateMcpCommand,
|
||||
buildSafeEnv,
|
||||
isSensitiveHeaderKey,
|
||||
serializeHeadersForStorage,
|
||||
parseStoredHeaders,
|
||||
} from '../mcp-manager.service';
|
||||
import { SLOMonitor, HealthChecker } from '../../utils/slo';
|
||||
|
||||
// better-sqlite3 的 ABI 可用性在模块顶层探测(describe.skipIf 在注册期求值)
|
||||
let dbAvailable = false;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const DatabaseProbe = require('better-sqlite3');
|
||||
const p = new DatabaseProbe(':memory:');
|
||||
p.close();
|
||||
dbAvailable = true;
|
||||
} catch {
|
||||
dbAvailable = false;
|
||||
}
|
||||
|
||||
// ===== MCP:safeParseArgs =====
|
||||
|
||||
describe('safeParseArgs — JSON args 解析', () => {
|
||||
@@ -75,6 +66,54 @@ describe('safeParseHeaders — headers 列解析', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ===== v0.7.4 P2-5: MCP headers 敏感值加密落库 =====
|
||||
|
||||
describe('MCP headers 敏感值加密(P2-5)', () => {
|
||||
it('isSensitiveHeaderKey 识别鉴权类键', () => {
|
||||
expect(isSensitiveHeaderKey('Authorization')).toBe(true);
|
||||
expect(isSensitiveHeaderKey('authorization')).toBe(true);
|
||||
expect(isSensitiveHeaderKey('X-API-Key')).toBe(true);
|
||||
expect(isSensitiveHeaderKey('Proxy-Authorization')).toBe(true);
|
||||
expect(isSensitiveHeaderKey('x-auth-token')).toBe(true);
|
||||
expect(isSensitiveHeaderKey('Accept')).toBe(false);
|
||||
expect(isSensitiveHeaderKey('Content-Type')).toBe(false);
|
||||
expect(isSensitiveHeaderKey('X-Route')).toBe(false);
|
||||
});
|
||||
|
||||
it('serializeHeadersForStorage 加密敏感值、普通值明文', () => {
|
||||
const stored = JSON.parse(
|
||||
serializeHeadersForStorage({
|
||||
Authorization: 'Bearer secret-token',
|
||||
'X-Route': 'gateway',
|
||||
}),
|
||||
) as Record<string, string>;
|
||||
// 敏感值带加密前缀(safeStorage 在测试环境不可用则明文 + WARN —— 兼容两种)
|
||||
if (stored.Authorization !== 'Bearer secret-token') {
|
||||
expect(String(stored.Authorization)).toMatch(/^metona-enc:v1:/);
|
||||
}
|
||||
expect(stored['X-Route']).toBe('gateway');
|
||||
});
|
||||
|
||||
it('parseStoredHeaders 解密加密值、透传明文(历史兼容)', () => {
|
||||
// 明文历史数据原样透传
|
||||
expect(parseStoredHeaders('{"Authorization":"Bearer plain"}')).toEqual({
|
||||
Authorization: 'Bearer plain',
|
||||
});
|
||||
// 加密值解密还原(若 safeStorage 可用)
|
||||
const stored = JSON.parse(serializeHeadersForStorage({ 'X-API-Key': 'k-123456' })) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const parsed = parseStoredHeaders(JSON.stringify(stored));
|
||||
if (String(stored['X-API-Key']).startsWith('metona-enc:v1:')) {
|
||||
expect(parsed?.['X-API-Key']).toBe('k-123456');
|
||||
} else {
|
||||
// safeStorage 不可用时明文存储 —— 行为与 encryptConfigValue 降级一致
|
||||
expect(parsed?.['X-API-Key']).toBe('k-123456');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===== MCP:validateMcpCommand =====
|
||||
|
||||
describe('validateMcpCommand — stdio 命令白名单防线', () => {
|
||||
@@ -217,7 +256,10 @@ describe('SLOMonitor — 窗口指标 / 分位数 / 燃烧率', () => {
|
||||
|
||||
// ===== HealthChecker =====
|
||||
|
||||
describe.skipIf(!dbAvailable)('HealthChecker — 三项健康检查', () => {
|
||||
describe('HealthChecker — 三项健康检查(v0.7.4 移除多余 skipIf)', () => {
|
||||
// v0.7.4 回归修复: 本套件用注入的 broken DB,不依赖 better-sqlite3 ABI,
|
||||
// 原 skipIf(!dbAvailable) 使系统 Node 下整个套件被跳过(与 slo.test.ts 的
|
||||
// 无门控 HealthChecker 套件门控不一致)。移除门控后双轨均执行。
|
||||
// 注:真实库连通性由 database-migration.test.ts(PRAGMA user_version 套件)以
|
||||
// 完整 DatabaseService.initialize() 覆盖;此处仅保留纯依赖注入的确定性用例。
|
||||
|
||||
|
||||
@@ -49,10 +49,10 @@ function readLines(sessionId: string): Array<Record<string, unknown>> {
|
||||
}
|
||||
|
||||
describe('SessionRecorder — 基本录制链路', () => {
|
||||
it('startRecording 会话开启后首条事件即 session_start(seq 0,经 flush 落盘)', () => {
|
||||
it('startRecording 会话开启后首条事件即 session_start(seq 0,经 flush 落盘)', async () => {
|
||||
recorder.startRecording('s1');
|
||||
// 事件先进缓冲(100ms 定时 flush),stopRecording 的 flushSync 保证落盘后断言
|
||||
recorder.stopRecording('s1', {
|
||||
await recorder.stopRecording('s1', {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
@@ -66,7 +66,7 @@ describe('SessionRecorder — 基本录制链路', () => {
|
||||
expect(lines[1].event).toBe('session_end');
|
||||
});
|
||||
|
||||
it('stopRecording 同步 flush 全部缓冲并写 session_end(#35 契约)', () => {
|
||||
it('stopRecording 同步 flush 全部缓冲并写 session_end(#35 契约)', async () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.recordToolCall({
|
||||
sessionId: 's1',
|
||||
@@ -75,7 +75,7 @@ describe('SessionRecorder — 基本录制链路', () => {
|
||||
args: { p: 'x' },
|
||||
});
|
||||
recorder.recordIterationStart('s1', 1);
|
||||
recorder.stopRecording('s1', {
|
||||
await recorder.stopRecording('s1', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 100,
|
||||
durationMs: 50,
|
||||
@@ -93,11 +93,11 @@ describe('SessionRecorder — 基本录制链路', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('事件 seq 在会话内单调递增', () => {
|
||||
it('事件 seq 在会话内单调递增', async () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.recordIterationStart('s1', 1);
|
||||
recorder.recordIterationStart('s1', 2);
|
||||
recorder.stopRecording('s1', {
|
||||
await recorder.stopRecording('s1', {
|
||||
totalIterations: 2,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
@@ -109,7 +109,7 @@ describe('SessionRecorder — 基本录制链路', () => {
|
||||
expect(seqs).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('recordLLMResponse 载荷:content 截断 200 字符', () => {
|
||||
it('recordLLMResponse 载荷:content 截断 200 字符', async () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.recordLLMResponse({
|
||||
sessionId: 's1',
|
||||
@@ -118,7 +118,7 @@ describe('SessionRecorder — 基本录制链路', () => {
|
||||
finishReason: 'stop',
|
||||
tokenUsage: { input: 10, output: 20, total: 30 },
|
||||
});
|
||||
recorder.stopRecording('s1', {
|
||||
await recorder.stopRecording('s1', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 30,
|
||||
durationMs: 1,
|
||||
@@ -130,7 +130,7 @@ describe('SessionRecorder — 基本录制链路', () => {
|
||||
expect(llm.tokenUsage).toEqual({ input: 10, output: 20, total: 30 });
|
||||
});
|
||||
|
||||
it('recordToolResult 载荷:success/durationMs/resultPreview 截断 500/error', () => {
|
||||
it('recordToolResult 载荷:success/durationMs/resultPreview 截断 500/error', async () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.recordToolResult({
|
||||
sessionId: 's1',
|
||||
@@ -141,7 +141,7 @@ describe('SessionRecorder — 基本录制链路', () => {
|
||||
resultPreview: 'z'.repeat(800),
|
||||
error: 'HTTP 403',
|
||||
});
|
||||
recorder.stopRecording('s1', {
|
||||
await recorder.stopRecording('s1', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
@@ -157,20 +157,20 @@ describe('SessionRecorder — 基本录制链路', () => {
|
||||
});
|
||||
|
||||
describe('SessionRecorder — 多会话隔离(P1-6)', () => {
|
||||
it('并发录制:每会话独立文件与独立 seq,互不串扰', () => {
|
||||
it('并发录制:每会话独立文件与独立 seq,互不串扰', async () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.startRecording('s2');
|
||||
|
||||
recorder.recordToolCall({ sessionId: 's1', iteration: 1, toolName: 'tool_a', args: {} });
|
||||
recorder.recordToolCall({ sessionId: 's2', iteration: 1, toolName: 'tool_b', args: {} });
|
||||
|
||||
recorder.stopRecording('s1', {
|
||||
await recorder.stopRecording('s1', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
recorder.stopRecording('s2', {
|
||||
await recorder.stopRecording('s2', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
@@ -186,29 +186,29 @@ describe('SessionRecorder — 多会话隔离(P1-6)', () => {
|
||||
expect(s2Tools[0].seq).toBe(1);
|
||||
});
|
||||
|
||||
it('未 startRecording 的会话事件被静默丢弃', () => {
|
||||
it('未 startRecording 的会话事件被静默丢弃', async () => {
|
||||
recorder.recordToolCall({ sessionId: 'ghost', iteration: 1, toolName: 'x', args: {} });
|
||||
expect(recorder.getFilePath('ghost')).toBeNull();
|
||||
});
|
||||
|
||||
it('stopRecording 幂等安全(未开始也会话状态不崩)', () => {
|
||||
expect(() =>
|
||||
it('stopRecording 幂等安全(未开始也会话状态不崩)', async () => {
|
||||
await expect(
|
||||
recorder.stopRecording('ghost', {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'error',
|
||||
}),
|
||||
).not.toThrow();
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionRecorder — 总开关与缓冲上限', () => {
|
||||
it('setEnabled(false) 后事件全部丢弃(logging.traceEnabled 契约)', () => {
|
||||
it('setEnabled(false) 后事件全部丢弃(logging.traceEnabled 契约)', async () => {
|
||||
recorder.setEnabled(false);
|
||||
recorder.startRecording('s1');
|
||||
recorder.recordToolCall({ sessionId: 's1', iteration: 1, toolName: 'x', args: {} });
|
||||
recorder.stopRecording('s1', {
|
||||
await recorder.stopRecording('s1', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
@@ -220,13 +220,13 @@ describe('SessionRecorder — 总开关与缓冲上限', () => {
|
||||
expect(files.filter((f) => f.startsWith('session_s1_'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('缓冲超过 MAX_BUFFER_SIZE 强制同步落盘(防 OOM)', () => {
|
||||
it('缓冲超过 MAX_BUFFER_SIZE 强制同步落盘(防 OOM)', async () => {
|
||||
recorder.startRecording('s1');
|
||||
// MAX_BUFFER_SIZE = 1000 —— 写入超限触发 flushSync(文件应提前出现在磁盘)
|
||||
for (let i = 0; i < 1001; i++) {
|
||||
recorder.recordIterationStart('s1', i);
|
||||
}
|
||||
recorder.stopRecording('s1', {
|
||||
await recorder.stopRecording('s1', {
|
||||
totalIterations: 1001,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
@@ -237,10 +237,10 @@ describe('SessionRecorder — 总开关与缓冲上限', () => {
|
||||
expect(lines.length).toBe(1003); // 1001 iterations + session_start + session_end
|
||||
});
|
||||
|
||||
it('getFilePath 返回活动会话的录制文件路径;stop 后清除', () => {
|
||||
it('getFilePath 返回活动会话的录制文件路径;stop 后清除', async () => {
|
||||
recorder.startRecording('s1');
|
||||
expect(recorder.getFilePath('s1')).toContain('session_s1_');
|
||||
recorder.stopRecording('s1', {
|
||||
await recorder.stopRecording('s1', {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
@@ -306,4 +306,346 @@ describe('SessionRecorder — 录制文件统计与清理(P3-3)', () => {
|
||||
expect(recorder.pruneOldRecordings(200)).toBe(0);
|
||||
expect(existsSync(join(wsRoot, 'logs', 'session_x.jsonl'))).toBe(true);
|
||||
});
|
||||
|
||||
it('pruneOldRecordings:maxFiles=0 时全部删除(保留 0 个)', async () => {
|
||||
await writeRecording('session_a.jsonl', 1);
|
||||
await writeRecording('session_b.jsonl', 2);
|
||||
const deleted = recorder.pruneOldRecordings(0);
|
||||
expect(deleted).toBe(2);
|
||||
const remaining = (readdirSync(join(wsRoot, 'logs')) as string[]).filter((f) =>
|
||||
f.startsWith('session_'),
|
||||
);
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('pruneOldRecordings:目录不存在返回 0(不抛错)', () => {
|
||||
expect(recorder.pruneOldRecordings(10)).toBe(0);
|
||||
});
|
||||
|
||||
it('pruneOldRecordings:stat 失败的文件按 mtime=0 排最后(仍参与清理计数)', async () => {
|
||||
await writeRecording('session_f1.jsonl', 1);
|
||||
await writeRecording('session_f2.jsonl', 2);
|
||||
const logsDir = join(wsRoot, 'logs');
|
||||
// 构造 stat 抛错的文件:删除后 mtime 读取失败场景通过目录删除模拟 ——
|
||||
// 直接验证排序兜底不抛错
|
||||
expect(() => recorder.pruneOldRecordings(1)).not.toThrow();
|
||||
expect(readdirSync(logsDir).filter((f) => f.startsWith('session_'))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionRecorder — flush 竞态与缓冲超限(P3-4)', () => {
|
||||
it('异步 flush 进行中 stopRecording 等待其完成且不丢数据', async () => {
|
||||
recorder.startRecording('race1');
|
||||
recorder.recordIterationStart('race1', 1);
|
||||
|
||||
// 触发 100ms 定时 flush(异步)
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
// 再次写事件后立即 stop —— flushSync 需等待 in-flight flush
|
||||
recorder.recordIterationStart('race1', 2);
|
||||
await recorder.stopRecording('race1', {
|
||||
totalIterations: 2,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
|
||||
const lines = readLines('race1');
|
||||
const events = lines.map((l) => l.event);
|
||||
// session_start + iteration_start×2 + session_end —— 无丢失
|
||||
expect(events).toEqual(['session_start', 'iteration_start', 'iteration_start', 'session_end']);
|
||||
});
|
||||
|
||||
it('缓冲超限内联落盘:超限行即时落盘,文件在 stop 前已含早期事件', async () => {
|
||||
recorder.startRecording('buf');
|
||||
// MAX_BUFFER_SIZE = 1000;写入 1001 条触发内联 appendFileSync
|
||||
for (let i = 0; i < 1001; i++) {
|
||||
recorder.recordIterationStart('buf', i);
|
||||
}
|
||||
// 无需 stop:超限 flush 已写入前 1000 行(含 session_start)
|
||||
const dir = join(wsRoot, 'logs');
|
||||
const files = (readdirSync(dir) as string[]).filter((f) => f.startsWith('session_buf_'));
|
||||
expect(files).toHaveLength(1);
|
||||
const content = readFileSync(join(dir, files[0]), 'utf-8');
|
||||
const lines = content.split('\n').filter((l) => l.trim());
|
||||
expect(lines.length).toBeGreaterThanOrEqual(1000);
|
||||
// 超限内联写的是"当前 buffer 不含本行",最后一行应仍留在 buffer 中
|
||||
expect(JSON.parse(lines[0]).event).toBe('session_start');
|
||||
await recorder.stopRecording('buf', {
|
||||
totalIterations: 1001,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
const finalLines = readLines('buf');
|
||||
expect(finalLines.length).toBe(1003); // 1001 + start + end
|
||||
});
|
||||
|
||||
it('多个会话并发触发异步 flush 互不串扰', async () => {
|
||||
recorder.startRecording('s1');
|
||||
recorder.startRecording('s2');
|
||||
recorder.recordIterationStart('s1', 1);
|
||||
recorder.recordIterationStart('s2', 1);
|
||||
recorder.recordIterationStart('s1', 2);
|
||||
recorder.recordIterationStart('s2', 2);
|
||||
await new Promise((r) => setTimeout(r, 200)); // 等待定时 flush
|
||||
await recorder.stopRecording('s1', {
|
||||
totalIterations: 2,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
await recorder.stopRecording('s2', {
|
||||
totalIterations: 2,
|
||||
totalTokens: 0,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
expect(readLines('s1').filter((l) => l.event === 'iteration_start')).toHaveLength(2);
|
||||
expect(readLines('s2').filter((l) => l.event === 'iteration_start')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('连续多次 stopRecording:第二次为 no-op(状态已清除)', async () => {
|
||||
recorder.startRecording('s1');
|
||||
const params = {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'completed',
|
||||
};
|
||||
await recorder.stopRecording('s1', params);
|
||||
await recorder.stopRecording('s1', params); // 不抛错
|
||||
expect(recorder.getFilePath('s1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionRecorder — 事件载荷形状', () => {
|
||||
it('recordContextBuilt 载荷:tokens 与 ratio 字段', async () => {
|
||||
recorder.startRecording('shape1');
|
||||
recorder.recordContextBuilt('shape1', { tokenCount: 8000, usageRatio: 0.32 });
|
||||
await recorder.stopRecording('shape1', {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
const evt = readLines('shape1').find((l) => l.event === 'context_built') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(evt.tokens).toBe(8000);
|
||||
expect(evt.ratio).toBe(0.32);
|
||||
expect(evt.sessionId).toBe('shape1');
|
||||
});
|
||||
|
||||
it('recordLLMRequest 载荷:provider/model/messageCount/iteration', async () => {
|
||||
recorder.startRecording('shape2');
|
||||
recorder.recordLLMRequest({
|
||||
sessionId: 'shape2',
|
||||
iteration: 3,
|
||||
provider: 'deepseek',
|
||||
model: 'v4-pro',
|
||||
messageCount: 7,
|
||||
});
|
||||
await recorder.stopRecording('shape2', {
|
||||
totalIterations: 3,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
const evt = readLines('shape2').find((l) => l.event === 'llm_request') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(evt).toMatchObject({
|
||||
provider: 'deepseek',
|
||||
model: 'v4-pro',
|
||||
messageCount: 7,
|
||||
iteration: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('recordLLMResponse 载荷:contentPreview 截断 200、finishReason、tokenUsage', async () => {
|
||||
recorder.startRecording('shape3');
|
||||
recorder.recordLLMResponse({
|
||||
sessionId: 'shape3',
|
||||
iteration: 1,
|
||||
content: 'x'.repeat(300),
|
||||
finishReason: 'length',
|
||||
tokenUsage: { input: 100, output: 50, total: 150 },
|
||||
});
|
||||
await recorder.stopRecording('shape3', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 150,
|
||||
durationMs: 1,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
const evt = readLines('shape3').find((l) => l.event === 'llm_response') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect((evt.contentPreview as string).length).toBe(200);
|
||||
expect(evt.finishReason).toBe('length');
|
||||
expect(evt.tokenUsage).toEqual({ input: 100, output: 50, total: 150 });
|
||||
});
|
||||
|
||||
it('recordIterationEnd 载荷:iteration 与 durationMs', async () => {
|
||||
recorder.startRecording('shape4');
|
||||
recorder.recordIterationEnd('shape4', { iteration: 2, durationMs: 1234 });
|
||||
await recorder.stopRecording('shape4', {
|
||||
totalIterations: 2,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
const evt = readLines('shape4').find((l) => l.event === 'iteration_end') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(evt.iteration).toBe(2);
|
||||
expect(evt.durationMs).toBe(1234);
|
||||
});
|
||||
|
||||
it('recordToolCall 载荷:tool 与 args 对象', async () => {
|
||||
recorder.startRecording('shape5');
|
||||
recorder.recordToolCall({
|
||||
sessionId: 'shape5',
|
||||
iteration: 1,
|
||||
toolName: 'git_diff',
|
||||
args: { a: 1, nested: { k: 'v' } },
|
||||
});
|
||||
await recorder.stopRecording('shape5', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
const evt = readLines('shape5').find((l) => l.event === 'tool_call') as Record<string, unknown>;
|
||||
expect(evt.tool).toBe('git_diff');
|
||||
expect(evt.args).toEqual({ a: 1, nested: { k: 'v' } });
|
||||
});
|
||||
|
||||
it('recordToolResult 无 resultPreview/error 时字段为 undefined(JSON 序列化后省略)', async () => {
|
||||
recorder.startRecording('shape6');
|
||||
recorder.recordToolResult({
|
||||
sessionId: 'shape6',
|
||||
iteration: 1,
|
||||
toolName: 'exec',
|
||||
success: true,
|
||||
durationMs: 5,
|
||||
});
|
||||
await recorder.stopRecording('shape6', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
const evt = readLines('shape6').find((l) => l.event === 'tool_result') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(evt.success).toBe(true);
|
||||
expect(evt.durationMs).toBe(5);
|
||||
expect(evt.resultPreview).toBeUndefined();
|
||||
expect(evt.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('session_end 事件载荷:totalIterations/totalTokens/durationMs/terminationReason', async () => {
|
||||
recorder.startRecording('shape7');
|
||||
await recorder.stopRecording('shape7', {
|
||||
totalIterations: 9,
|
||||
totalTokens: 7777,
|
||||
durationMs: 5500,
|
||||
terminationReason: 'max_tokens',
|
||||
});
|
||||
const end = readLines('shape7').find((l) => l.event === 'session_end') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(end).toMatchObject({
|
||||
totalIterations: 9,
|
||||
totalTokens: 7777,
|
||||
durationMs: 5500,
|
||||
terminationReason: 'max_tokens',
|
||||
});
|
||||
expect(end.seq).toBe(1); // start=0, end=1
|
||||
});
|
||||
|
||||
it('每条事件都带 sessionId 与 ts(ISO 时间戳)', async () => {
|
||||
recorder.startRecording('shape8');
|
||||
recorder.recordIterationStart('shape8', 1);
|
||||
await recorder.stopRecording('shape8', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
for (const line of readLines('shape8')) {
|
||||
expect(line.sessionId).toBe('shape8');
|
||||
expect(new Date(line.ts as string).getTime()).not.toBeNaN();
|
||||
}
|
||||
});
|
||||
|
||||
it('文件名含时间戳且唯一(ISO 格式无冒号)', () => {
|
||||
recorder.startRecording('filename1');
|
||||
recorder.startRecording('filename1'); // 同 id 二次 start 覆盖
|
||||
const path = recorder.getFilePath('filename1') as string;
|
||||
expect(path).toMatch(/session_filename1_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.jsonl$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionRecorder — 生命周期统计细节', () => {
|
||||
it('getRecordingStats 统计单个文件字节数正确', async () => {
|
||||
const logsDir = join(wsRoot, 'logs');
|
||||
const fs = await import('node:fs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
const content = '{"a":1}\n{"b":2}\n';
|
||||
fs.writeFileSync(join(logsDir, 'session_x.jsonl'), content, 'utf-8');
|
||||
const stats = recorder.getRecordingStats();
|
||||
expect(stats.count).toBe(1);
|
||||
expect(stats.totalBytes).toBe(Buffer.byteLength(content));
|
||||
});
|
||||
|
||||
it('getRecordingStats 目录无 session 文件时 count=0', async () => {
|
||||
const logsDir = join(wsRoot, 'logs');
|
||||
const fs = await import('node:fs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
fs.writeFileSync(join(logsDir, 'other.txt'), 'x', 'utf-8');
|
||||
const stats = recorder.getRecordingStats();
|
||||
expect(stats.count).toBe(0);
|
||||
expect(stats.totalBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('startRecording 自动创建 logs 目录并返回符合格式的路径', () => {
|
||||
expect(existsSync(join(wsRoot, 'logs'))).toBe(false);
|
||||
recorder.startRecording('dir1');
|
||||
// 目录立即创建
|
||||
expect(existsSync(join(wsRoot, 'logs'))).toBe(true);
|
||||
// 路径符合 session_<id>_<ISO 时间戳>.jsonl 格式(文件在首次 flush 后落盘)
|
||||
const path = recorder.getFilePath('dir1') as string;
|
||||
expect(path).toContain('session_dir1_');
|
||||
expect(path.endsWith('.jsonl')).toBe(true);
|
||||
// 触发 flush 后文件出现在磁盘
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
expect(existsSync(path)).toBe(true);
|
||||
resolve();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
it('setEnabled(true) 恢复录制(开关可逆;禁用期事件被丢弃)', async () => {
|
||||
recorder.setEnabled(false);
|
||||
recorder.startRecording('toggle');
|
||||
// 禁用期:session_start 被丢弃
|
||||
recorder.setEnabled(true);
|
||||
recorder.recordIterationStart('toggle', 1);
|
||||
await recorder.stopRecording('toggle', {
|
||||
totalIterations: 1,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'completed',
|
||||
});
|
||||
const events = readLines('toggle').map((l) => l.event);
|
||||
expect(events).toEqual(['iteration_start', 'session_end']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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-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);
|
||||
@@ -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('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,
|
||||
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 透传与 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,
|
||||
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('编辑后');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -443,3 +443,427 @@ describe.skipIf(!dbAvailable)('session_summaries 级联删除(F-3)', () => {
|
||||
expect(remaining.map((r) => r.session_id)).toEqual(['s_keep']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* maybeSummarize 触发与增量重摘要(P2-11 滚动摘要的核心触发语义)
|
||||
*
|
||||
* 触发条件(源码契约):
|
||||
* 1. 总消息数 >= MIN_MESSAGES_TO_SUMMARIZE(50)
|
||||
* 2. 未总结条数 > TAIL_KEEP + MIN_NEW_TO_SUMMARIZE(35),且
|
||||
* 3. 摘出后 toSummarize(未总结前 N-20 条)>= MIN_NEW_TO_SUMMARIZE(15)
|
||||
* LLM 返回空串/抛错 → 静默降级不落库。
|
||||
*/
|
||||
describe.skipIf(!dbAvailable)('maybeSummarize — 触发阈值与增量', () => {
|
||||
let db: any;
|
||||
let sessionService: any;
|
||||
let summaryService: any;
|
||||
let sendMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
const createSuite = async (): Promise<void> => {
|
||||
const { SessionService } = await import('../session.service');
|
||||
const { SessionSummaryService } = await import('../session-summary.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 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);
|
||||
sendMock = vi.fn().mockResolvedValue({ content: '滚动摘要内容' });
|
||||
const adapter = {
|
||||
providerId: 'mock',
|
||||
supportedModels: [],
|
||||
supportsToolCalling: false,
|
||||
supportsThinking: false,
|
||||
getContextWindow: () => 1000,
|
||||
send: sendMock,
|
||||
sendStream: vi.fn(),
|
||||
healthCheck: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
summaryService = new SessionSummaryService(
|
||||
() => db,
|
||||
sessionService,
|
||||
() => adapter,
|
||||
);
|
||||
};
|
||||
|
||||
const insertMany = (sessionId: string, n: number, start = 1): void => {
|
||||
for (let i = start; i < start + n; i++) {
|
||||
db.prepare(
|
||||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(`${sessionId}_m${i}`, sessionId, i % 2 === 1 ? 'user' : 'assistant', `消息 ${i}`, i);
|
||||
}
|
||||
};
|
||||
|
||||
it('消息数 < 50 时触发摘要,LLM 不被调用', async () => {
|
||||
await createSuite();
|
||||
db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('thr_49', 1, 1)`).run();
|
||||
insertMany('thr_49', 49);
|
||||
await summaryService.maybeSummarize('thr_49');
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(summaryService.getSummary('thr_49')).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('消息数 >= 50 时触发摘要并保存摘要与游标', async () => {
|
||||
await createSuite();
|
||||
db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('thr_50', 1, 1)`).run();
|
||||
insertMany('thr_50', 50);
|
||||
await summaryService.maybeSummarize('thr_50');
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
const summary = summaryService.getSummary('thr_50');
|
||||
expect(summary).not.toBeNull();
|
||||
// 摘出 30 条(50-20),游标指向第 30 条 rowid
|
||||
const lastToSummarize = db
|
||||
.prepare(
|
||||
`SELECT MAX(rowid) AS r FROM messages WHERE session_id = 'thr_50' AND id = 'thr_50_m30'`,
|
||||
)
|
||||
.get() as { r: number };
|
||||
expect(summary.summarizedUntilRowid).toBe(lastToSummarize.r);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('85 条消息时摘要覆盖前 65 条、保留尾部 20 条原文', async () => {
|
||||
await createSuite();
|
||||
db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('thr_85', 1, 1)`).run();
|
||||
insertMany('thr_85', 85);
|
||||
await summaryService.maybeSummarize('thr_85');
|
||||
const summary = summaryService.getSummary('thr_85');
|
||||
expect(summary).not.toBeNull();
|
||||
const tailStart = db
|
||||
.prepare(`SELECT rowid AS r FROM messages WHERE session_id = 'thr_85' AND id = 'thr_85_m66'`)
|
||||
.get() as { r: number };
|
||||
expect(summary.summarizedUntilRowid).toBe(tailStart.r - 1);
|
||||
// 分层加载:摘要 + 尾部 20 条原文
|
||||
const history = summaryService.buildHistoryMessages('thr_85');
|
||||
expect(history.length).toBe(21); // 1 摘要 + 20 原文
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('增量重摘要:已有摘要后新增 35 条再次摘要(游标前移)', async () => {
|
||||
await createSuite();
|
||||
db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('incr', 1, 1)`).run();
|
||||
insertMany('incr', 85);
|
||||
await summaryService.maybeSummarize('incr');
|
||||
const first = summaryService.getSummary('incr');
|
||||
expect(first).not.toBeNull();
|
||||
|
||||
// 再插入 35 条(rowid 86-120)
|
||||
insertMany('incr', 35, 86);
|
||||
await summaryService.maybeSummarize('incr');
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
const second = summaryService.getSummary('incr')!;
|
||||
expect(second.summarizedUntilRowid).toBeGreaterThan(first!.summarizedUntilRowid);
|
||||
// 第二轮请求携带前序摘要(滚动合并)
|
||||
const priorPrompt = sendMock.mock.calls[1][0] as { messages: Array<{ content: string }> };
|
||||
expect(priorPrompt.messages[0].content).toContain('## Prior summary');
|
||||
expect(priorPrompt.messages[0].content).toContain('滚动摘要内容');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('增量不足(新增 <= 35 条)不重复调用 LLM', async () => {
|
||||
await createSuite();
|
||||
db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('noincr', 1, 1)`).run();
|
||||
insertMany('noincr', 85);
|
||||
await summaryService.maybeSummarize('noincr');
|
||||
const calls = sendMock.mock.calls.length;
|
||||
// 只加 5 条
|
||||
insertMany('noincr', 5, 86);
|
||||
await summaryService.maybeSummarize('noincr');
|
||||
expect(sendMock.mock.calls.length).toBe(calls);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('LLM 返回空串 → 不保存摘要(静默降级)', async () => {
|
||||
await createSuite();
|
||||
db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('empty', 1, 1)`).run();
|
||||
insertMany('empty', 60);
|
||||
sendMock.mockResolvedValueOnce({ content: ' ' });
|
||||
await expect(summaryService.maybeSummarize('empty')).resolves.toBeUndefined();
|
||||
expect(summaryService.getSummary('empty')).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('LLM 抛错 → 不抛异常、不保存摘要', async () => {
|
||||
await createSuite();
|
||||
db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('fail', 1, 1)`).run();
|
||||
insertMany('fail', 60);
|
||||
sendMock.mockRejectedValueOnce(new Error('timeout'));
|
||||
await expect(summaryService.maybeSummarize('fail')).resolves.toBeUndefined();
|
||||
expect(summaryService.getSummary('fail')).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('摘要请求参数锁定:maxTokens=2048、temperature=0、stream=false', async () => {
|
||||
await createSuite();
|
||||
db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('param', 1, 1)`).run();
|
||||
insertMany('param', 60);
|
||||
await summaryService.maybeSummarize('param');
|
||||
const req = sendMock.mock.calls[0][0] as {
|
||||
params: { maxTokens: number; temperature: number; stream: boolean };
|
||||
};
|
||||
expect(req.params.maxTokens).toBe(2048);
|
||||
expect(req.params.temperature).toBe(0);
|
||||
expect(req.params.stream).toBe(false);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('buildHistoryMessages — 无摘要与 role 过滤', () => {
|
||||
let db: any;
|
||||
let sessionService: any;
|
||||
let summaryService: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { SessionService } = await import('../session.service');
|
||||
const { SessionSummaryService } = await import('../session-summary.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 TABLE session_summaries (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
summary TEXT NOT NULL,
|
||||
summarized_until_rowid INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES (?, ?, ?, 0)',
|
||||
).run('s_plain', Date.now(), Date.now());
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES (?, ?, ?, 0)',
|
||||
).run('s_role', Date.now(), Date.now());
|
||||
sessionService = new SessionService(() => db);
|
||||
summaryService = new SessionSummaryService(
|
||||
() => db,
|
||||
sessionService,
|
||||
() => null as unknown as Parameters<typeof Object>[0],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
const insertMessage = (sessionId: string, id: string, role: string, content: string): void => {
|
||||
db.prepare(
|
||||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(id, sessionId, role, content, Date.now());
|
||||
};
|
||||
|
||||
it('无摘要时返回全量原文(不注入摘要消息)', () => {
|
||||
insertMessage('s_plain', 'p1', 'user', '甲');
|
||||
insertMessage('s_plain', 'p2', 'assistant', '乙');
|
||||
const history = summaryService.buildHistoryMessages('s_plain');
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history.some((m: any) => (m.content as string).startsWith('[Context Summary]'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('system 角色消息被过滤(不进 LLM 历史)', () => {
|
||||
insertMessage('s_role', 'r1', 'system', '系统提示');
|
||||
insertMessage('s_role', 'r2', 'user', '用户消息');
|
||||
const history = summaryService.buildHistoryMessages('s_role');
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].content).toBe('用户消息');
|
||||
});
|
||||
|
||||
it('消息按插入顺序返回且携带 timestamp 元数据', () => {
|
||||
insertMessage('s_plain', 'p3', 'user', '丙');
|
||||
const history = summaryService.buildHistoryMessages('s_plain');
|
||||
const contents = history.map((m: any) => m.content);
|
||||
expect(contents).toEqual(['甲', '乙', '丙']);
|
||||
expect(history.every((m: any) => typeof m.timestamp === 'number')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('restoreHistoryImages — 更多边界', () => {
|
||||
let db: any;
|
||||
let sessionService: any;
|
||||
let summaryService: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { SessionService } = await import('../session.service');
|
||||
const { SessionSummaryService } = await import('../session-summary.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 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);
|
||||
summaryService = new SessionSummaryService(
|
||||
() => db,
|
||||
sessionService,
|
||||
() => null as unknown as Parameters<typeof Object>[0],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
const insertWithAttachments = (
|
||||
sessionId: string,
|
||||
id: string,
|
||||
content: string,
|
||||
attachments: Array<{ type: string; preview?: string; textContent?: string }>,
|
||||
): void => {
|
||||
db.prepare(
|
||||
`INSERT INTO messages (id, session_id, role, content, attachments, created_at) VALUES (?, ?, 'user', ?, ?, ?)`,
|
||||
).run(id, sessionId, content, JSON.stringify(attachments), Date.now());
|
||||
};
|
||||
|
||||
it('空 preview 的 image 附件不恢复为图片', () => {
|
||||
db.prepare(`DELETE FROM messages WHERE session_id = 's_img2'`).run();
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_img2', ?, ?, 0)`,
|
||||
).run(Date.now(), Date.now());
|
||||
insertWithAttachments('s_img2', 'img2_a', '空 preview 图', [{ type: 'image', preview: '' }]);
|
||||
const history = summaryService.buildHistoryMessages('s_img2');
|
||||
expect(history[0].images).toBeUndefined();
|
||||
});
|
||||
|
||||
it('单条消息含多张图:全部恢复(同消息内按数组序)', () => {
|
||||
db.prepare(`DELETE FROM messages WHERE session_id = 's_img3'`).run();
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_img3', ?, ?, 0)`,
|
||||
).run(Date.now(), Date.now());
|
||||
insertWithAttachments('s_img3', 'img3_a', '三图消息', [
|
||||
{ type: 'image', preview: 'PIC1' },
|
||||
{ type: 'image', preview: 'PIC2' },
|
||||
{ type: 'image', preview: 'PIC3' },
|
||||
]);
|
||||
const history = summaryService.buildHistoryMessages('s_img3');
|
||||
const urls = (history[0].images as Array<{ url: string }>).map((i) => i.url);
|
||||
expect(urls).toEqual(['PIC1', 'PIC2', 'PIC3']);
|
||||
});
|
||||
|
||||
it('10 张上限从最新收集:单消息 6 张 + 后 1 消息 6 张 → 后消息全收、前消息收 4 张', () => {
|
||||
db.prepare(`DELETE FROM messages WHERE session_id = 's_img4'`).run();
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_img4', ?, ?, 0)`,
|
||||
).run(Date.now(), Date.now());
|
||||
const six = Array.from({ length: 6 }, (_, i) => ({ type: 'image', preview: `OLD${i}` }));
|
||||
insertWithAttachments('s_img4', 'img4_old', '旧消息 6 图', six);
|
||||
const sixNew = Array.from({ length: 6 }, (_, i) => ({ type: 'image', preview: `NEW${i}` }));
|
||||
insertWithAttachments('s_img4', 'img4_new', '新消息 6 图', sixNew);
|
||||
|
||||
const history = summaryService.buildHistoryMessages('s_img4');
|
||||
const oldMsg = history.find((m: any) => m.content === '旧消息 6 图');
|
||||
const newMsg = history.find((m: any) => m.content === '新消息 6 图');
|
||||
expect((newMsg!.images as unknown[]).length).toBe(6);
|
||||
// 剩 4 张配额给更早的消息(取靠后 4 张)
|
||||
expect((oldMsg!.images as unknown[]).length).toBe(4);
|
||||
const oldUrls = (oldMsg!.images as Array<{ url: string }>).map((i) => i.url);
|
||||
expect(oldUrls).toEqual(['OLD2', 'OLD3', 'OLD4', 'OLD5']);
|
||||
});
|
||||
|
||||
it('非 image 类型附件不恢复(text/file 类型忽略)', () => {
|
||||
db.prepare(`DELETE FROM messages WHERE session_id = 's_img5'`).run();
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_img5', ?, ?, 0)`,
|
||||
).run(Date.now(), Date.now());
|
||||
insertWithAttachments('s_img5', 'img5_a', '混合附件', [
|
||||
{ type: 'text', textContent: 't' },
|
||||
{ type: 'file', preview: 'FILEPREVIEW' },
|
||||
{ type: 'image', preview: 'REALIMG' },
|
||||
]);
|
||||
const history = summaryService.buildHistoryMessages('s_img5');
|
||||
const images = history[0].images as Array<{ url: string }>;
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0].url).toBe('REALIMG');
|
||||
});
|
||||
|
||||
it('无任何附件消息不产生 images 字段', () => {
|
||||
db.prepare(`DELETE FROM messages WHERE session_id = 's_img6'`).run();
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_img6', ?, ?, 0)`,
|
||||
).run(Date.now(), Date.now());
|
||||
db.prepare(
|
||||
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES ('img6_a', 's_img6', 'user', '纯文本', ?)`,
|
||||
).run(Date.now());
|
||||
const history = summaryService.buildHistoryMessages('s_img6');
|
||||
expect(history[0].images).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,10 +78,12 @@ function makeAdapter(content: string | Error): IMetonaProviderAdapter {
|
||||
}
|
||||
|
||||
function makeSessionService(existing: Array<{ id: string; title: string }> = []) {
|
||||
// 注意:真实 SessionService.list() 是同步方法(better-sqlite3),
|
||||
// 注意:真实 SessionService.list()/getSession() 是同步方法(better-sqlite3),
|
||||
// mock 必须保持同步返回,否则 generate 内的 .find() 拿到 Promise
|
||||
return {
|
||||
list: vi.fn(() => existing) as unknown as () => Array<{ id: string; title: string }>,
|
||||
// v0.7.4: TitleGenerator 改用 getSession(含归档会话)判断已有标题
|
||||
getSession: vi.fn((id: string) => existing.find((s) => s.id === id) ?? null),
|
||||
rename: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
@@ -124,6 +126,28 @@ describe('TitleGenerator.maybeGenerateTitle', () => {
|
||||
expect(adapterSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// v0.7.4 修复: 前端自动截断标题(首条消息前 30 字符)不是"自定义标题",
|
||||
// 应被 LLM 标题覆盖 —— 旧实现把截断标题误判为自定义标题,LLM 标题永不生成
|
||||
it('T3b: 前端自动截断标题仍生成 LLM 标题覆盖(v0.7.4 根因修复)', async () => {
|
||||
// 长消息(>30 字符)→ 前端自动标题 = 前 30 字符 + '...'
|
||||
const userMsg = '帮我看看为什么登录总是超时而且偶尔还会直接崩溃然后闪退重试也不行';
|
||||
const autoTitle = userMsg.slice(0, 30) + '...';
|
||||
const svc = makeSessionService([{ id: 's1', title: autoTitle }]);
|
||||
const gen = new TitleGenerator(() => makeAdapter('修复登录超时'), svc as never);
|
||||
const title = await gen.maybeGenerateTitle('s1', userMsg, '已定位为 token 过期…');
|
||||
expect(title).toBe('修复登录超时');
|
||||
expect(svc.rename).toHaveBeenCalledWith('s1', '修复登录超时');
|
||||
});
|
||||
|
||||
it('T3c: 短消息(≤30 字符)的自动截断标题(=原文)仍生成 LLM 标题覆盖', async () => {
|
||||
const userMsg = '解释一下事件循环';
|
||||
const svc = makeSessionService([{ id: 's2', title: userMsg }]);
|
||||
const gen = new TitleGenerator(() => makeAdapter('事件循环机制'), svc as never);
|
||||
const title = await gen.maybeGenerateTitle('s2', userMsg, '事件循环是…');
|
||||
expect(title).toBe('事件循环机制');
|
||||
expect(svc.rename).toHaveBeenCalledWith('s2', '事件循环机制');
|
||||
});
|
||||
|
||||
it('T4: LLM 失败静默回退(返回 null,不抛错),且同会话不重试', async () => {
|
||||
const svc = makeSessionService([{ id: 's1', title: '新会话' }]);
|
||||
const gen = new TitleGenerator(() => makeAdapter(new Error('network down')), svc as never);
|
||||
|
||||
@@ -168,3 +168,248 @@ describe('WorkspaceService — 时间戳与 reload', () => {
|
||||
expect(svc.getFiles().soul).not.toBe('tampered');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceService — appendMemory 分区定位(v0.7.2 根治)', () => {
|
||||
it('中文分区(用户偏好)条目追加到分区末尾而非文件末尾(v0.7.2 缺陷回归)', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
|
||||
svc.appendMemory('用户偏好', '第一条');
|
||||
svc.appendMemory('项目上下文', '项目第一条');
|
||||
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
const userSection = content.slice(
|
||||
content.indexOf('## 用户偏好'),
|
||||
content.indexOf('## 项目上下文'),
|
||||
);
|
||||
// 项目上下文条目不应出现在用户偏好分区内(旧 bug 会创建重复分区头)
|
||||
expect(userSection).toContain('- 第一条');
|
||||
expect(userSection).not.toContain('项目第一条');
|
||||
// 项目分区只有一份(没有重复的 ## 项目上下文)
|
||||
expect(content.match(/## 项目上下文/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('已存在分区:多条条目顺序追加(后写在后)', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
svc.appendMemory('用户偏好', 'A');
|
||||
svc.appendMemory('用户偏好', 'B');
|
||||
svc.appendMemory('用户偏好', 'C');
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
const userSection = content.slice(
|
||||
content.indexOf('## 用户偏好'),
|
||||
content.indexOf('## 项目上下文'),
|
||||
);
|
||||
expect(userSection.indexOf('- A')).toBeLessThan(userSection.indexOf('- B'));
|
||||
expect(userSection.indexOf('- B')).toBeLessThan(userSection.indexOf('- C'));
|
||||
});
|
||||
|
||||
it('新分区(不存在)追加到文件末尾并创建分区头', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
svc.appendMemory('新增分区', '新内容');
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
// 新分区位于文件末尾
|
||||
expect(content.trimEnd().endsWith('- 新内容')).toBe(true);
|
||||
expect(content.match(/## 新增分区/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('分区名含正则元字符(括号)时不被误转义破坏', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
svc.appendMemory('用户偏好(移动端)', '移动端条目');
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(content).toContain('## 用户偏好(移动端)');
|
||||
expect(content).toContain('- 移动端条目');
|
||||
});
|
||||
|
||||
it('前缀相似分区互不误匹配("用户偏好" vs "用户偏好(旧)")', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
writeFileSync(
|
||||
join(wsPath, 'MEMORY.md'),
|
||||
'# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n\n## 用户偏好(旧)\n- 旧条目\n\n## 项目上下文\n',
|
||||
'utf-8',
|
||||
);
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.appendMemory('用户偏好', '新条目');
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
// 新条目追加到"用户偏好"分区(在(旧)分区后创建独立分区)
|
||||
const mainSection = content.slice(content.indexOf('## 用户偏好\n'));
|
||||
expect(mainSection).toContain('- 新条目');
|
||||
expect(content).toContain('## 用户偏好\n');
|
||||
});
|
||||
|
||||
it('appendMemory 更新"最后更新"时间戳行', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
svc.appendMemory('用户偏好', 'x');
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(content).toMatch(/> 最后更新: \d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('MEMORY.md 不存在时 appendMemory 静默返回(不抛错)', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
expect(() => svc.appendMemory('用户偏好', 'x')).not.toThrow();
|
||||
});
|
||||
|
||||
it('条目内容含换行与 markdown 语法原样保留', () => {
|
||||
const wsPath = join(wsRoot, 'ws');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
svc.appendMemory('项目上下文', '第一行\n第二行 `code`');
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(content).toContain('- 第一行\n第二行 `code`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceService — validateMemoryFormat 自愈矩阵', () => {
|
||||
const setup = (content: string): { wsPath: string; svc: WorkspaceService } => {
|
||||
const wsPath = join(wsRoot, `ws_${Math.random().toString(36).slice(2, 8)}`);
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
writeFileSync(join(wsPath, 'MEMORY.md'), content, 'utf-8');
|
||||
return { wsPath, svc: new WorkspaceService(wsPath) };
|
||||
};
|
||||
|
||||
it('缺失"创建时间"元数据 → 自动补全并返回 false', () => {
|
||||
const { wsPath, svc } = setup('# MEMORY.md\n> 最后更新: x\n> 工作空间: y\n');
|
||||
expect(svc.validateMemoryFormat()).toBe(false);
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(content).toContain('> 创建时间:');
|
||||
});
|
||||
|
||||
it('缺失"最后更新"元数据 → 自动补全', () => {
|
||||
const { wsPath, svc } = setup('# MEMORY.md\n> 创建时间: x\n> 工作空间: y\n');
|
||||
expect(svc.validateMemoryFormat()).toBe(false);
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(content).toContain('> 最后更新:');
|
||||
});
|
||||
|
||||
it('缺失"工作空间"元数据 → 自动补全为当前工作空间路径', () => {
|
||||
const { wsPath, svc } = setup('# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n');
|
||||
expect(svc.validateMemoryFormat()).toBe(false);
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(content).toContain(`> 工作空间: ${wsPath}`);
|
||||
});
|
||||
|
||||
it('缺失核心分区(用户偏好/项目上下文/重要决策)→ 自动补齐全部', () => {
|
||||
const { wsPath, svc } = setup('# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n');
|
||||
expect(svc.validateMemoryFormat()).toBe(false);
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
for (const section of ['## 用户偏好', '## 项目上下文', '## 重要决策']) {
|
||||
expect(content).toContain(section);
|
||||
}
|
||||
});
|
||||
|
||||
it('缺"# MEMORY"标题时元数据插入到文件开头(三条元数据均在原内容之前)', () => {
|
||||
const { wsPath, svc } = setup('- 无标题内容\n');
|
||||
expect(svc.validateMemoryFormat()).toBe(false);
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
// 元数据插入在开头
|
||||
expect(content.indexOf('> 创建时间:')).toBeLessThan(content.indexOf('- 无标题内容'));
|
||||
expect(content.indexOf('> 最后更新:')).toBeLessThan(content.indexOf('- 无标题内容'));
|
||||
expect(content.indexOf('> 工作空间:')).toBeLessThan(content.indexOf('- 无标题内容'));
|
||||
expect(content).toContain('- 无标题内容');
|
||||
});
|
||||
|
||||
it('完整文件(含标题+元数据+核心分区)返回 true 且不改写', () => {
|
||||
const { wsPath, svc } = setup(
|
||||
'# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n\n## 用户偏好\n\n## 项目上下文\n\n## 重要决策\n',
|
||||
);
|
||||
const before = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(svc.validateMemoryFormat()).toBe(true);
|
||||
const after = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('MEMORY.md 不存在时 validateMemoryFormat 返回 false 不抛错', () => {
|
||||
const wsPath = join(wsRoot, 'ws_nofile');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
expect(svc.validateMemoryFormat()).toBe(false);
|
||||
});
|
||||
|
||||
it('自愈后 getFiles().memory 同步为修正后的内容', () => {
|
||||
const { svc } = setup('# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n');
|
||||
svc.validateMemoryFormat();
|
||||
expect(svc.getFiles().memory).toContain('## 重要决策');
|
||||
});
|
||||
|
||||
it('重复调用自愈幂等:修正后再次验证返回 true', () => {
|
||||
const { svc } = setup('# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n');
|
||||
expect(svc.validateMemoryFormat()).toBe(false);
|
||||
expect(svc.validateMemoryFormat()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceService — initialize 与路径边界', () => {
|
||||
it('嵌套目录路径自动创建(多级不存在目录)', () => {
|
||||
const wsPath = join(wsRoot, 'a', 'b', 'c');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
const info = svc.initialize();
|
||||
expect(info.isValid).toBe(true);
|
||||
expect(existsSync(join(wsPath, 'SOUL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('getPath 返回构造时传入的路径', () => {
|
||||
const wsPath = join(wsRoot, 'ws_path');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
expect(svc.getPath()).toBe(wsPath);
|
||||
});
|
||||
|
||||
it('initialize 重复调用幂等(不抛错、内容不丢失)', () => {
|
||||
const wsPath = join(wsRoot, 'ws_reinit');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
writeFileSync(join(wsPath, 'SOUL.md'), '# 自定义人格', 'utf-8');
|
||||
svc.initialize();
|
||||
expect(svc.getFiles().soul).toBe('# 自定义人格');
|
||||
});
|
||||
|
||||
it('SOUL.md 自动创建内容包含灵魂定义标题', () => {
|
||||
const wsPath = join(wsRoot, 'ws_soul');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
const content = readFileSync(join(wsPath, 'SOUL.md'), 'utf-8');
|
||||
expect(content).toContain('# SOUL.md');
|
||||
expect(content).toContain('身份、性格和核心价值观');
|
||||
});
|
||||
|
||||
it('MEMORY.md 模板的工作空间路径占位符被替换', () => {
|
||||
const wsPath = join(wsRoot, 'ws_placeholder');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
|
||||
expect(content).toContain(`> 工作空间: ${wsPath}`);
|
||||
expect(content).not.toContain('__WORKSPACE_PATH__');
|
||||
});
|
||||
|
||||
it('initialize 返回 files 快照(不可变副本)', () => {
|
||||
const wsPath = join(wsRoot, 'ws_snapshot');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
const info = svc.initialize();
|
||||
info.files.soul = 'mutated';
|
||||
expect(svc.getFiles().soul).not.toBe('mutated');
|
||||
});
|
||||
|
||||
it('reload 同步 MEMORY.md 外部修改并触发格式校验', () => {
|
||||
const wsPath = join(wsRoot, 'ws_reload');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
svc.initialize();
|
||||
writeFileSync(join(wsPath, 'MEMORY.md'), '> 创建时间: x\n', 'utf-8');
|
||||
svc.reload();
|
||||
// reload 后内存内容与磁盘一致(自愈补全在 validateMemoryFormat 中写回)
|
||||
expect(svc.getFiles().memory).toContain('> 创建时间: x');
|
||||
});
|
||||
|
||||
it('updateMemoryTimestamp 对不存在的 MEMORY.md 静默返回', () => {
|
||||
const wsPath = join(wsRoot, 'ws_nots');
|
||||
const svc = new WorkspaceService(wsPath);
|
||||
expect(() => svc.updateMemoryTimestamp()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user