Files
peoplelib/src/_test/ai-sessions.test.js
T

879 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const sessionsPath = require.resolve('../reader/ai-sessions.js');
const imagesPath = require.resolve('../reader/ai-images.js');
const dirs = [];
function tmp(tag) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-ai-sessions-${tag}-`));
dirs.push(dir);
return dir;
}
function fresh(tag = 'x') {
const dir = tmp(tag);
return at(dir);
}
function at(dir) {
delete require.cache[sessionsPath];
delete require.cache[imagesPath];
const images = require(imagesPath);
const sessions = require(sessionsPath);
images.init(dir);
sessions.init(dir);
return { sessions, images, dir };
}
function sessionDir(dir) {
return path.join(dir, 'reader-ai-sessions');
}
function sessionFile(dir, id) {
return path.join(sessionDir(dir), `${id}.json`);
}
function imageDir(dir) {
return path.join(dir, 'reader-ai-images');
}
// 最小可解码 JPEGSOI + APP0 + 一段填充 + EOI。put 只校验 SOI 魔数,内容差异即哈希差异。
function jpeg(seed, size = 64) {
const bytes = Buffer.alloc(size, seed & 0xff);
bytes[0] = 0xff;
bytes[1] = 0xd8;
bytes[2] = 0xff;
bytes[3] = 0xe0;
bytes[size - 2] = 0xff;
bytes[size - 1] = 0xd9;
return bytes;
}
// atomic-file 通过 fd 写入,因此统计 openSync('<file>.tmp','w') 而不是 writeFileSync 的路径
function countWrites(matcher, fn) {
const originalOpen = fs.openSync;
let writes = 0;
fs.openSync = function counting(target, flags, ...rest) {
if (matcher(String(target)) && String(flags).startsWith('w')) writes++;
return originalOpen.call(this, target, flags, ...rest);
};
try { fn(); } finally { fs.openSync = originalOpen; }
return writes;
}
function round(s, id, question, answer) {
s.appendUser(id, { text: question, task: 'ask' });
const placeholder = s.appendAssistant(id, {});
return s.finishAssistant(id, placeholder.id, { text: answer });
}
test.after(() => {
for (const dir of dirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
// --- 基本读写 ---
test('会话按 per-session 文件落盘,索引可枚举', () => {
const { sessions, dir } = fresh('basic');
const a = sessions.create({ entryId: 'e1', title: '甲会话' });
const b = sessions.create({ entryId: 'e2' });
assert.match(a.id, /^chat_[a-z0-9]+_[a-z0-9]+$/, '会话 ID 必须是可安全拼进文件名的形状');
assert.strictEqual(a.entryId, 'e1');
assert.strictEqual(b.entryId, 'e2');
const files = fs.readdirSync(sessionDir(dir)).sort();
assert.deepStrictEqual(files, ['index.json', `${a.id}.json`, `${b.id}.json`].sort(),
'每个会话应独占一个文件,加上一份索引');
assert.deepStrictEqual(sessions.list().map((row) => row.id).sort(), [a.id, b.id].sort());
assert.deepStrictEqual(sessions.list({ entryId: 'e1' }).map((row) => row.id), [a.id]);
assert.deepStrictEqual(sessions.list({ entryId: 'e404' }), []);
});
test('未指定 entryId 的会话落到 GLOBAL_ENTRY_ID,与独立笔记伪条目不同', () => {
const { sessions } = fresh('global');
const g = sessions.create({});
assert.strictEqual(g.entryId, 'system:global-chat');
assert.notStrictEqual(sessions.GLOBAL_ENTRY_ID, 'system:standalone-notes',
'全局会话与独立笔记是两个伪条目,混用会让笔记与会话互相污染');
});
test('list 按 pinned desc, updatedAt desc 排序', () => {
const { sessions } = fresh('sort');
const a = sessions.create({ title: 'a' });
const b = sessions.create({ title: 'b' });
const c = sessions.create({ title: 'c' });
sessions.rename(b.id, 'b2');
sessions.setPinned(a.id, true);
const ids = sessions.list().map((row) => row.id);
assert.strictEqual(ids[0], a.id, 'pinned 的会话必须排最前');
assert.ok(ids.indexOf(b.id) < ids.indexOf(c.id), '未 pin 的按 updatedAt 倒序');
});
test('一轮对话可完整读回,占位回答在 finish 时才成为消息', () => {
const { sessions } = fresh('round');
const meta = sessions.create({ entryId: 'e1' });
sessions.appendUser(meta.id, { text: '这是问题', task: 'ask' });
assert.strictEqual(sessions.messages(meta.id).messages.length, 1);
const placeholder = sessions.appendAssistant(meta.id, {});
assert.strictEqual(sessions.messages(meta.id).messages.length, 1,
'占位回答不落盘,否则流式过程中每个增量都会整文件重写');
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: '这是回答' });
assert.strictEqual(done.id, placeholder.id, 'finish 必须复用占位 ID,渲染层靠它对齐流式片段');
const read = sessions.messages(meta.id);
assert.deepStrictEqual(read.messages.map((m) => m.role), ['user', 'assistant']);
assert.strictEqual(read.messages[1].text, '这是回答');
assert.strictEqual(read.meta.messageCount, 2);
assert.strictEqual(read.meta.title, '这是问题', '首轮问题应自动成为标题');
});
test('finishAssistant 可记录取消与错误,且 cancelled 保留残片', () => {
const { sessions } = fresh('finish');
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: 'q' });
const p1 = sessions.appendAssistant(meta.id, {});
const cancelled = sessions.finishAssistant(meta.id, p1.id, { text: '半句', cancelled: true });
assert.strictEqual(cancelled.cancelled, true);
assert.strictEqual(cancelled.text, '半句', '中途停止时残片必须保留,用户看到的就是它');
sessions.appendUser(meta.id, { text: 'q2' });
const p2 = sessions.appendAssistant(meta.id, {});
const failed = sessions.finishAssistant(meta.id, p2.id, { text: '', error: '接口超时' });
assert.strictEqual(failed.error, '接口超时');
assert.throws(() => sessions.finishAssistant(meta.id, 'msg_nope', { text: 'x' }), /会话消息不存在/);
});
test('clear 清空消息但保留会话,remove 删除会话与索引行', () => {
const { sessions, dir } = fresh('lifecycle');
const meta = sessions.create({ entryId: 'e1', title: '保留' });
round(sessions, meta.id, '问', '答');
const cleared = sessions.clear(meta.id);
assert.strictEqual(cleared.messageCount, 0);
assert.strictEqual(cleared.droppedMessages, 0);
assert.strictEqual(cleared.title, '保留', 'clear 不应丢标题');
assert.strictEqual(sessions.list().length, 1);
assert.strictEqual(sessions.remove(meta.id), true);
assert.strictEqual(sessions.remove(meta.id), false);
assert.strictEqual(sessions.list().length, 0);
assert.ok(!fs.existsSync(sessionFile(dir, meta.id)));
assert.throws(() => sessions.messages(meta.id), /会话不存在/);
});
test('messages 支持 limit 与 before 游标翻页', () => {
const { sessions } = fresh('paging');
const meta = sessions.create({});
for (let i = 0; i < 5; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
const tail = sessions.messages(meta.id, { limit: 4 });
assert.strictEqual(tail.messages.length, 4);
assert.strictEqual(tail.hasMore, true);
assert.strictEqual(tail.messages[3].text, '答4');
const older = sessions.messages(meta.id, { limit: 4, before: tail.messages[0].id });
assert.strictEqual(older.messages[older.messages.length - 1].id !== tail.messages[0].id, true,
'before 必须是排他的,否则翻页会重复一条');
assert.throws(() => sessions.messages(meta.id, { before: 'msg_missing' }), /会话消息不存在/);
});
// --- 字段限长 ---
test('受限字段超长时截断而不是抛错', () => {
const { sessions } = fresh('limits');
const L = sessions.LIMITS;
const meta = sessions.create({ title: '标'.repeat(L.title + 50) });
assert.strictEqual(meta.title.length, L.title, `title 应截到 ${L.title}`);
const user = sessions.appendUser(meta.id, { text: '问'.repeat(L.question + 100) });
assert.strictEqual(user.text.length, L.question, `user 文本应截到 question=${L.question}`);
const p = sessions.appendAssistant(meta.id, {});
const done = sessions.finishAssistant(meta.id, p.id, {
text: '回答',
error: '错'.repeat(L.errorText + 100)
});
assert.strictEqual(done.text, '回答');
assert.strictEqual(done.error.length, L.errorText, `error 应截到 ${L.errorText}`);
const renamed = sessions.rename(meta.id, '新'.repeat(L.title + 10));
assert.strictEqual(renamed.title.length, L.title);
});
test('超过 20000 字的 assistant 正文经 append、finish 与磁盘恢复后保持完整', () => {
const { sessions, dir } = fresh('long-assistant');
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: '请生成长回答' });
const initial = `占位开头${'初'.repeat(21000)}占位结尾`;
const placeholder = sessions.appendAssistant(meta.id, { text: initial });
assert.strictEqual(placeholder.text, initial, 'appendAssistant 不应静默截断 assistant 正文');
const answer = `回答开头${'答'.repeat(25000)}回答结尾`;
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: answer });
assert.strictEqual(done.text, answer, 'finishAssistant 不应静默截断 assistant 正文');
const raw = JSON.parse(fs.readFileSync(sessionFile(dir, meta.id), 'utf8'));
assert.strictEqual(raw.messages[1].text, answer, '超过 20000 字的回答必须完整落盘');
const restored = at(dir).sessions.messages(meta.id);
assert.strictEqual(restored.messages[1].text, answer, '超过 20000 字的回答必须从磁盘完整读回');
});
test('标题规范化去掉控制字符与换行,自动标题取首轮问题前 40 字', () => {
const { sessions } = fresh('title');
const meta = sessions.create({ title: ' 带\n换行\u0007和控制符 ' });
assert.strictEqual(meta.title, '带 换行 和控制符', '标题会进索引 JSON,控制字符必须清掉');
const auto = sessions.create({});
sessions.appendUser(auto.id, { text: '标'.repeat(100) });
assert.strictEqual(sessions.messages(auto.id).meta.title.length, 40, '自动标题只取前 40 字');
});
test('非法上下文与任务被拒,contextRef 只在 user 消息上存在', () => {
const { sessions } = fresh('shape');
const meta = sessions.create({});
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', contextRef: { scope: 'whatever' }
}), /会话上下文范围无效/);
assert.throws(() => sessions.appendUser(meta.id, { text: 'q', task: 'hack' }), /会话任务类型无效/);
const withContext = sessions.appendUser(meta.id, {
text: 'q',
contextRef: { scope: 'page', text: '正文内容', clipped: true, locator: { page: 3 }, documentKey: 'dk', fileIndex: 2 }
});
assert.strictEqual(withContext.contextRef.chars, 4);
assert.strictEqual(withContext.contextRef.hash, sessions.hashContext('正文内容'));
assert.strictEqual(withContext.contextRef.hash.length, 32, 'hash 固定 32 hex,用于判断上下文是否变了');
assert.strictEqual(withContext.contextRef.clipped, true);
assert.deepStrictEqual(withContext.contextRef.locator, { page: 3 });
const p = sessions.appendAssistant(meta.id, { contextRef: { scope: 'page', text: 'x' } });
assert.strictEqual(p.contextRef, null, 'assistant 消息不应带 contextRef');
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', contextRef: { scope: 'page', locator: { blob: 'x'.repeat(60000) } }
}), /定位信息/);
});
// --- 上限策略 ---
test('会话数达上限抛中文错误,且不删除已有会话', () => {
const { sessions, dir } = fresh('total');
const total = sessions.LIMITS.sessionsTotal;
const ids = [];
for (let i = 0; i < total; i++) ids.push(sessions.create({ title: `会话${i}` }).id);
const before = fs.readdirSync(sessionDir(dir)).length;
assert.throws(() => sessions.create({ title: '溢出' }), /会话数量已达上限/,
'达到上限必须拒绝新建,绝不能静默删用户的旧会话');
assert.strictEqual(fs.readdirSync(sessionDir(dir)).length, before, '拒绝新建不应改动磁盘');
assert.strictEqual(sessions.list().length, total);
sessions.remove(ids[0]);
assert.ok(sessions.create({ title: '腾出位置后可建' }).id, '删掉一个后应能继续新建');
});
test('单会话消息达上限成对淘汰,首轮保留且不出现孤立 assistant', () => {
const { sessions } = fresh('evict');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: '首轮问题', contextRef: { scope: 'document', text: '正文' } });
const first = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, first.id, { text: '首轮回答' });
for (let i = 0; i < max; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
const read = sessions.messages(meta.id, { limit: max });
assert.ok(read.meta.messageCount <= max, `消息数不得超过 ${max}`);
assert.strictEqual(read.messages[0].text, '首轮问题', '承载正文的首轮必须永久保留');
assert.ok(read.messages[0].contextRef, '首轮的 contextRef 不能被淘汰掉');
assert.ok(read.meta.droppedMessages > 0, 'droppedMessages 必须累加,供历史里的省略标记使用');
assert.strictEqual(read.meta.droppedMessages % 2, 0, '成对淘汰时丢弃数应为偶数');
assert.strictEqual(
read.meta.messageCount + read.meta.droppedMessages,
(max + 1) * 2,
'保留数加丢弃数应等于写入总数,说明没有额外丢失'
);
const roles = read.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
'成对淘汰后不允许出现连续同角色,否则历史里会有没有提问的孤立回答');
}
});
// appendUser 之后、finishAssistant 之前的这个中间态就是构造请求时 historyFor 看到的状态。
// 逐条丢弃在整轮结束时会被第二次丢弃恰好补回来,只有在中间态才能看出角色错位。
test('淘汰后的中间态也不出现连续同角色', () => {
const { sessions } = fresh('evict-mid');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
for (let i = 0; i < max / 2; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
assert.strictEqual(sessions.messages(meta.id, { limit: max }).meta.messageCount, max, '先填满到上限');
sessions.appendUser(meta.id, { text: '新问题' });
const read = sessions.messages(meta.id, { limit: max });
assert.strictEqual(read.meta.droppedMessages, 2, '一次淘汰应成对丢弃两条,而不是单条');
const roles = read.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
'中间态出现连续同角色,说明淘汰是逐条而非成对,这会让历史里出现两条相邻的回答');
}
assert.strictEqual(roles[0], 'user');
assert.strictEqual(read.messages[0].text, '问0', '首轮提问必须还在');
assert.strictEqual(roles[roles.length - 1], 'user');
const out = sessions.historyFor(meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
assert.strictEqual(
out.messages.length,
read.messages.length,
'中间态角色已经交替,historyFor 不该再需要合并任何消息;需要合并说明淘汰留下了错位'
);
});
test('图像数达上限时丢弃最早消息的图像引用但保留文本', () => {
const { sessions, images } = fresh('img-evict');
const max = sessions.LIMITS.imagesPerSession;
const meta = sessions.create({});
const ids = [];
for (let i = 0; i <= max; i++) {
const put = images.put(jpeg(i + 1), 'image/jpeg');
ids.push(put.imageId);
sessions.appendUser(meta.id, {
text: `第 ${i} 张`,
images: [{ imageId: put.imageId, mimeType: 'image/jpeg', width: 10, height: 10, bytes: put.bytes }]
});
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: `答 ${i}` });
}
const read = sessions.messages(meta.id, { limit: 500 });
const totalImages = read.messages.reduce((n, m) => n + m.images.length, 0);
assert.ok(totalImages <= max, `会话内图像引用不得超过 ${max}`);
assert.strictEqual(read.messages[0].text, '第 0 张', '丢图像引用不应丢文本');
assert.strictEqual(read.messages[0].images.length, 0, '最早的图像引用先被丢弃');
assert.ok(!sessions.imageIds().includes(ids[0]), '被丢弃的引用不应再出现在 GC 白名单里');
});
// --- 文件隔离与写放大 ---
test('追加消息只重写自己那个会话文件', () => {
const { sessions, dir } = fresh('isolate');
const a = sessions.create({ title: 'a' });
const b = sessions.create({ title: 'b' });
const bFile = sessionFile(dir, b.id);
const before = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
round(sessions, a.id, '问', '答');
const after = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
assert.strictEqual(after, before, '写 a 不应碰 b 的字节,否则 100 个会话就是 100 倍写放大');
const writesToB = countWrites((t) => t === `${bFile}.tmp`, () => {
round(sessions, a.id, '问2', '答2');
});
assert.strictEqual(writesToB, 0, '其他会话文件的写入次数必须是 0');
});
test('一轮对话对会话文件只写两次,索引写入不随消息数增长', () => {
const { sessions, dir } = fresh('writes');
const meta = sessions.create({});
const file = sessionFile(dir, meta.id);
const isSession = (t) => t === `${file}.tmp`;
const oneRound = countWrites(isSession, () => {
sessions.appendUser(meta.id, { text: '问' });
const p = sessions.appendAssistant(meta.id, {});
for (let i = 0; i < 50; i++) sessions.appendAssistant; // 流式增量不经过存储层
sessions.finishAssistant(meta.id, p.id, { text: '答'.repeat(500) });
});
assert.strictEqual(oneRound, 2, '一轮对话最多两次会话文件写入:appendUser 一次,finishAssistant 一次');
// 对照:两轮就是四次,证明计数器没失灵
const twoRounds = countWrites(isSession, () => {
round(sessions, meta.id, '问2', '答2');
round(sessions, meta.id, '问3', '答3');
});
assert.strictEqual(twoRounds, 4, '两轮应写四次,用于对照说明上面的 2 不是计数器失灵');
const idxFile = path.join(sessionDir(dir), 'index.json');
const idxWrites = countWrites((t) => t === `${idxFile}.tmp`, () => {
round(sessions, meta.id, '问4', '答4');
});
assert.strictEqual(idxWrites, 2, '索引跟着会话文件一起更新,一轮两次');
});
test('会话文件解析结果按内容缓存,同 mtime 同体积但内容不同也不会读到旧值', () => {
const { sessions, dir } = fresh('cache');
const meta = sessions.create({});
round(sessions, meta.id, '原始问题', '原始回答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
// utimesSync 会丢掉 mtime 的小数毫秒,先钉成整秒再取基准,否则构造本身对不上
const fixed = new Date(1700000000000);
fs.utimesSync(file, fixed, fixed);
const stat = fs.statSync(file);
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '原始问题');
// 等长替换 + 还原 mtime:按 mtime+size 判定的缓存在这里会返回旧内容
raw.messages[0].text = '篡改问题';
const rewritten = JSON.stringify(raw, null, 2);
assert.strictEqual(Buffer.byteLength(rewritten, 'utf8'), stat.size, '构造用例要求体积不变');
fs.writeFileSync(file, rewritten);
fs.utimesSync(file, fixed, fixed);
assert.strictEqual(fs.statSync(file).mtimeMs, stat.mtimeMs, '构造用例要求 mtime 不变');
assert.strictEqual(fs.statSync(file).size, stat.size, '构造用例要求体积不变');
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '篡改问题',
'缓存必须按内容哈希失效,按 mtime+size 会漏掉同毫秒内的改写');
});
// --- 损坏自愈 ---
test('主文件损坏时从 .bak 恢复', () => {
const { sessions, dir } = fresh('bak');
const meta = sessions.create({ entryId: 'e1' });
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
fs.copyFileSync(file, `${file}.bak`);
fs.writeFileSync(file, '{ 这不是 JSON');
const read = sessions.messages(meta.id);
assert.strictEqual(read.messages.length, 2, '.bak 完好时必须恢复出完整对话');
assert.strictEqual(read.messages[0].text, '问');
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
assert.strictEqual(corrupt.length, 1, '损坏的主文件应被隔离留档而不是直接删除');
});
test('主文件与 .bak 都损坏时隔离成 .corrupt- 并返回空会话', () => {
const { sessions, dir } = fresh('corrupt');
const meta = sessions.create({ entryId: 'e1', title: '标题' });
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
fs.writeFileSync(file, 'broken');
fs.writeFileSync(`${file}.bak`, 'also broken');
const read = sessions.messages(meta.id);
assert.deepStrictEqual(read.messages, [], '两份都坏只能返回空,不能抛错让界面卡死');
assert.strictEqual(read.meta.entryId, 'e1', '空会话应从索引行恢复出归属,否则会变成孤立会话');
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
assert.strictEqual(corrupt.length, 1);
round(sessions, meta.id, '新问', '新答');
assert.strictEqual(sessions.messages(meta.id).messages.length, 2, '自愈后应能继续写入');
});
test('index.json 丢失后扫目录重建,list 结果正确', () => {
const { sessions, dir } = fresh('reindex');
const a = sessions.create({ entryId: 'e1', title: '甲' });
const b = sessions.create({ entryId: 'e2', title: '乙' });
round(sessions, a.id, '问', '答');
const idxFile = path.join(sessionDir(dir), 'index.json');
fs.unlinkSync(idxFile);
const reloaded = at(dir).sessions;
const rows = reloaded.list();
assert.strictEqual(rows.length, 2, '索引是派生缓存,丢失后必须能从会话文件重建');
const rowA = rows.find((r) => r.id === a.id);
assert.strictEqual(rowA.title, '甲');
assert.strictEqual(rowA.entryId, 'e1');
assert.strictEqual(rowA.messageCount, 2);
assert.ok(rowA.bytes > 0, '重建的索引行应带上真实体积');
assert.strictEqual(rows.find((r) => r.id === b.id).messageCount, 0);
assert.ok(fs.existsSync(idxFile), '重建结果应回写磁盘');
});
test('index.json 损坏时重建,且损坏的索引不会让会话消失', () => {
const { sessions, dir } = fresh('reindex2');
const a = sessions.create({ title: '甲' });
fs.writeFileSync(path.join(sessionDir(dir), 'index.json'), '[[[not json');
const reloaded = at(dir).sessions;
assert.deepStrictEqual(reloaded.list().map((r) => r.id), [a.id]);
});
test('索引里混入非法行时被丢弃,真实会话仍在', () => {
const { sessions, dir } = fresh('reindex3');
const a = sessions.create({ title: '甲' });
const idxFile = path.join(sessionDir(dir), 'index.json');
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
idx.sessions.push({ id: '../escape', title: '恶意' });
idx.sessions.push({ id: '__proto__', title: '污染' });
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
const rows = at(dir).sessions.list();
assert.deepStrictEqual(rows.map((r) => r.id), [a.id], '索引里的非法 ID 必须被丢弃');
});
test('会话文件里的坏消息被逐条丢弃,其余消息仍可读', () => {
const { sessions, dir } = fresh('bad-msg');
const meta = sessions.create({});
round(sessions, meta.id, '好问题', '好回答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages.splice(1, 0, { role: 'nobody', text: '坏消息' }, null, 'not an object');
raw.messages.push({ role: 'assistant', text: '尾部回答', images: 'not an array', task: '非法' });
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const read = at(dir).sessions.messages(meta.id);
assert.deepStrictEqual(read.messages.map((m) => m.text), ['好问题', '好回答', '尾部回答'],
'会话内容部分来自模型输出,坏条目要丢弃而不是让整个会话读不出来');
assert.deepStrictEqual(read.messages[2].images, []);
assert.strictEqual(read.messages[2].task, null);
});
// --- ID 校验 ---
test('非法会话 ID 被拒,不产生任何文件', () => {
const { sessions, dir } = fresh('ids');
const bad = ['', '../escape', 'a/b', 'a\\b', '__proto__', 'prototype', 'constructor',
'.', '..', '-lead', 'x'.repeat(200), 'a.b', 'a:b', null, undefined, 'chat_ok\u0000'];
for (const id of bad) {
assert.throws(() => sessions.messages(id), /会话 ID 无效/, `会话 ID ${JSON.stringify(id)} 必须被拒`);
assert.throws(() => sessions.remove(id), /会话 ID 无效/);
assert.throws(() => sessions.appendUser(id, { text: 'x' }), /会话 ID 无效/);
}
assert.ok(!fs.existsSync(sessionDir(dir)) || !fs.readdirSync(sessionDir(dir)).some((n) => n !== 'index.json'),
'被拒的 ID 不应在磁盘上留下文件');
});
test('非法 entryId 与 messageId 被拒', () => {
const { sessions } = fresh('ids2');
for (const entryId of ['../x', 'a/b', '__proto__', 'x'.repeat(200), '.', '..']) {
assert.throws(() => sessions.create({ entryId }), /会话条目 ID 无效/);
}
const meta = sessions.create({ entryId: 'e1' });
for (const msgId of ['', '../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
assert.throws(() => sessions.finishAssistant(meta.id, msgId, { text: 'x' }), /会话消息 ID 无效/);
}
for (const msgId of ['../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
assert.throws(() => sessions.messages(meta.id, { before: msgId }), /会话消息 ID 无效/);
}
assert.doesNotThrow(() => sessions.messages(meta.id, { before: '' }), '空游标等价于不传,取最新一页');
});
test('finishAssistant 不能跨会话认领占位消息', () => {
const { sessions } = fresh('cross');
const a = sessions.create({});
const b = sessions.create({});
sessions.appendUser(a.id, { text: 'q' });
const p = sessions.appendAssistant(a.id, {});
assert.throws(() => sessions.finishAssistant(b.id, p.id, { text: '越界' }), /会话消息不存在/,
'占位消息必须绑定会话,否则渲染层可以把回答写进别的会话');
});
// --- historyFor ---
function history(sessions, id, budget) {
return sessions.historyFor(id, budget);
}
test('historyFor 始终 pin 首条 user 消息', () => {
const { sessions } = fresh('hist-pin');
const meta = sessions.create({});
sessions.appendUser(meta.id, { text: `正文${'甲'.repeat(300)}`, contextRef: { scope: 'document', text: 'x' } });
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: '首答' });
for (let i = 0; i < 20; i++) round(sessions, meta.id, `问${i}`.repeat(20), `答${i}`.repeat(20));
const out = history(sessions, meta.id, { maxChars: 600, maxMessages: 6, maxMessageChars: 400 });
assert.strictEqual(out.messages[0].role, 'user');
assert.ok(out.messages[0].text.includes('正文'),
'承载正文的首条 user 必须永远在历史里,否则后续追问会失去参照');
assert.ok(out.messages.length < 42, '预算内应确实丢掉了中间轮次');
assert.ok(out.dropped > 0);
});
test('historyFor 的省略标记折进现有消息而不是新增消息', () => {
const { sessions } = fresh('hist-mark');
const meta = sessions.create({});
for (let i = 0; i < 12; i++) round(sessions, meta.id, `问题${i}`.repeat(20), `回答${i}`.repeat(20));
const out = history(sessions, meta.id, { maxChars: 500, maxMessages: 5, maxMessageChars: 400 });
const marked = out.messages.filter((m) => /已省略较早的 \d+ 轮对话/.test(m.text));
assert.strictEqual(marked.length, 1, '省略标记只应出现一次');
assert.strictEqual(marked[0], out.messages[0], '标记必须折进最旧那条保留消息');
assert.ok(marked[0].text.length > '[……已省略较早的 1 轮对话……]\n'.length,
'标记是折进去的,所以这条消息里还应有原本的正文,而不是一条只有标记的空消息');
assert.ok(out.messages.every((m) => m.role === 'user' || m.role === 'assistant'),
'不允许为了放标记而造出 system 之类的新角色');
});
test('historyFor 总字符不超过 maxChars', () => {
const { sessions } = fresh('hist-total');
const meta = sessions.create({});
for (let i = 0; i < 15; i++) round(sessions, meta.id, `问${i}`.repeat(50), `答${i}`.repeat(50));
for (const maxChars of [200, 500, 1200, 3000]) {
const out = history(sessions, meta.id, { maxChars, maxMessages: 30, maxMessageChars: 2000 });
const total = out.messages.reduce((n, m) => n + m.text.length, 0);
assert.ok(total <= maxChars, `maxChars=${maxChars} 时实际 ${total} 字符,超预算会直接被接口拒绝`);
}
});
test('historyFor 单条超限中间挖空且保留尾部', () => {
const { sessions } = fresh('hist-clip');
const meta = sessions.create({});
const head = '开头标记';
const tail = '结尾标记';
sessions.appendUser(meta.id, { text: `${head}${'填'.repeat(3000)}${tail}` });
const p = sessions.appendAssistant(meta.id, {});
sessions.finishAssistant(meta.id, p.id, { text: '答' });
const out = history(sessions, meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 600 });
const first = out.messages[0];
assert.ok(first.text.length <= 600, '单条应被压到 maxMessageChars 以内');
assert.ok(first.text.startsWith(head), '挖空必须保留头部');
assert.ok(first.text.endsWith(tail), '挖空必须保留尾部,尾部往往是真正的提问');
assert.ok(first.text.includes('中间内容已省略'), '挖空处要有可见标记');
assert.strictEqual(first.truncated, true, 'truncated 标记供界面提示用户');
});
test('historyFor 裁剪后若首条是 assistant 则丢掉它', () => {
const { sessions, dir } = fresh('hist-lead');
const meta = sessions.create({});
round(sessions, meta.id, '问', '答');
// 恶性输入:直接把会话文件改成 assistant 领头,模拟历史数据或模型侧写坏
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages = [
{ id: 'msg_a1', role: 'assistant', text: '孤立回答', createdAt: 1 },
{ id: 'msg_u1', role: 'user', text: '真正的提问', createdAt: 2 },
{ id: 'msg_a2', role: 'assistant', text: '真正的回答', createdAt: 3 }
];
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const out = at(dir).sessions.historyFor(meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 400 });
assert.strictEqual(out.messages[0].role, 'user',
'Anthropic 的 /messages 要求 messages[0].role === "user",领头的 assistant 必须丢掉');
assert.ok(!out.messages.some((m) => m.text.includes('孤立回答')));
});
test('historyFor 不产生连续同角色消息', () => {
const { sessions, dir } = fresh('hist-roles');
const meta = sessions.create({});
round(sessions, meta.id, '问', '答');
const file = sessionFile(dir, meta.id);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
raw.messages = [
{ id: 'msg_u1', role: 'user', text: '问一', createdAt: 1 },
{ id: 'msg_u2', role: 'user', text: '问二', createdAt: 2 },
{ id: 'msg_u3', role: 'user', text: '问三', createdAt: 3 },
{ id: 'msg_a1', role: 'assistant', text: '答一', createdAt: 4 },
{ id: 'msg_a2', role: 'assistant', text: '答二', createdAt: 5 },
{ id: 'msg_u4', role: 'user', text: '问四', createdAt: 6 }
];
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
const reloaded = at(dir).sessions;
for (const budget of [
{ maxChars: 4000, maxMessages: 10, maxMessageChars: 400 },
{ maxChars: 20, maxMessages: 3, maxMessageChars: 50 },
{ maxChars: 4000, maxMessages: 2, maxMessageChars: 400 }
]) {
const out = reloaded.historyFor(meta.id, budget);
const roles = out.messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
assert.notStrictEqual(roles[i], roles[i - 1],
`预算 ${JSON.stringify(budget)} 下出现了连续同角色,Anthropic 会直接 400`);
}
if (roles.length) assert.strictEqual(roles[0], 'user');
assert.ok(out.messages.find((m) => m.text.includes('问一')), '首条 user 仍应被 pin');
}
});
test('historyFor 把已被物理丢弃的轮次计入省略标记', () => {
const { sessions } = fresh('hist-dropped');
const max = sessions.LIMITS.messagesPerSession;
const meta = sessions.create({});
for (let i = 0; i < max; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
const read = sessions.messages(meta.id, { limit: max });
assert.ok(read.meta.droppedMessages > 0);
const out = history(sessions, meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
assert.ok(out.dropped >= read.meta.droppedMessages,
'磁盘上已丢的轮次也要计入 dropped,否则模型会以为它看到了完整对话');
assert.match(out.messages[0].text, /已省略较早的 \d+ 轮对话/);
});
test('historyFor 在空会话与单条会话上不炸', () => {
const { sessions } = fresh('hist-edge');
const meta = sessions.create({});
assert.deepStrictEqual(sessions.historyFor(meta.id, {}), { messages: [], dropped: 0 });
sessions.appendUser(meta.id, { text: '只有一条' });
const out = sessions.historyFor(meta.id, { maxChars: 5, maxMessages: 1, maxMessageChars: 50 });
assert.strictEqual(out.messages.length, 1, '首条 user 即使超预算也要留下,否则请求没有内容可发');
assert.strictEqual(out.messages[0].role, 'user');
});
// --- imageIds 与 GC ---
test('imageIds 遍历全部会话文件,索引缺行也能找到引用', () => {
const { sessions, images, dir } = fresh('gc-scan');
const a = sessions.create({ title: '甲' });
const b = sessions.create({ title: '乙' });
const imgA = images.put(jpeg(11), 'image/jpeg');
const imgB = images.put(jpeg(22), 'image/jpeg');
sessions.appendUser(a.id, { text: '带图甲', images: [{ imageId: imgA.imageId, bytes: imgA.bytes }] });
sessions.appendUser(b.id, { text: '带图乙', images: [{ imageId: imgB.imageId, bytes: imgB.bytes }] });
// 故意让索引缺掉 b:只读索引的 GC 会把 b 引用的图当垃圾删掉
const idxFile = path.join(sessionDir(dir), 'index.json');
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
idx.sessions = idx.sessions.filter((row) => row.id !== b.id);
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
const reloaded = at(dir);
assert.ok(!reloaded.sessions.list().some((row) => row.id === b.id) || true);
const ids = reloaded.sessions.imageIds();
assert.ok(ids.includes(imgB.imageId),
'imageIds 必须扫目录而不是读索引,索引损坏时只读索引会静默删掉仍被引用的图');
assert.ok(ids.includes(imgA.imageId));
const removed = reloaded.images.cleanup(ids, { graceMs: 0 });
assert.strictEqual(removed, 0, '全部图仍被引用时不该删任何东西');
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgB.imageId}.jpg`)));
});
test('imageIds 覆盖 .bak 里的引用与内存中的占位消息', () => {
const { sessions, images, dir } = fresh('gc-bak');
const meta = sessions.create({});
const img = images.put(jpeg(33), 'image/jpeg');
sessions.appendUser(meta.id, { text: '带图', images: [{ imageId: img.imageId, bytes: img.bytes }] });
const file = sessionFile(dir, meta.id);
fs.copyFileSync(file, `${file}.bak`);
fs.writeFileSync(file, 'broken');
const reloaded = at(dir);
assert.ok(reloaded.sessions.imageIds().includes(img.imageId),
'主文件坏掉但 .bak 还引用着这张图,GC 不能删它');
const pendingImg = images.put(jpeg(44), 'image/jpeg');
const s2 = fresh('gc-pending');
const m2 = s2.sessions.create({});
s2.sessions.appendUser(m2.id, { text: 'q' });
s2.sessions.appendAssistant(m2.id, { images: [{ imageId: pendingImg.imageId, bytes: pendingImg.bytes }] });
assert.ok(s2.sessions.imageIds().includes(pendingImg.imageId),
'未落盘的占位消息引用的图也要在白名单里,否则流式期间的 GC 会删掉它');
});
test('会话删除后其图像被 cleanup 回收', () => {
const { sessions, images, dir } = fresh('gc-remove');
const keep = sessions.create({ title: '留' });
const drop = sessions.create({ title: '删' });
const imgKeep = images.put(jpeg(55), 'image/jpeg');
const imgDrop = images.put(jpeg(66), 'image/jpeg');
sessions.appendUser(keep.id, { text: 'k', images: [{ imageId: imgKeep.imageId, bytes: imgKeep.bytes }] });
sessions.appendUser(drop.id, { text: 'd', images: [{ imageId: imgDrop.imageId, bytes: imgDrop.bytes }] });
sessions.remove(drop.id);
const removed = images.cleanup(sessions.imageIds(), { graceMs: 0 });
assert.strictEqual(removed, 1);
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgKeep.imageId}.jpg`)));
assert.ok(!fs.existsSync(path.join(imageDir(dir), `${imgDrop.imageId}.jpg`)));
});
// --- ai-images ---
test('ai-images 只收 JPEG,魔数不对直接拒', () => {
const { images } = fresh('img-mime');
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
assert.throws(() => images.put(png, 'image/png'), /仅支持 JPEG/);
assert.throws(() => images.put(png, 'image/jpeg'), /不是有效的 JPEG/,
'声明 JPEG 但内容是 PNG 必须被拒,否则视觉接口会收到无法解码的负载');
assert.throws(() => images.put(Buffer.alloc(0), 'image/jpeg'), /数据为空/);
assert.throws(() => images.put(Buffer.alloc(64, 0), 'image/jpeg'), /不是有效的 JPEG/);
assert.throws(() => images.put('not a buffer', 'image/jpeg'), /数据为空/);
assert.throws(() => images.put(jpeg(1, 4 * 1024 * 1024), 'image/jpeg'), /超过 3 MB/);
});
test('ai-images 内容寻址去重,同图两次 put 只占一份磁盘', () => {
const { images, dir } = fresh('img-dedup');
const bytes = jpeg(77, 1024);
const first = images.put(bytes, 'image/jpeg');
const second = images.put(Buffer.from(bytes), 'image/jpeg');
assert.strictEqual(second.imageId, first.imageId, '同样的字节必须得到同样的 imageId');
assert.strictEqual(fs.readdirSync(imageDir(dir)).length, 1, '去重后磁盘上只应有一份');
assert.strictEqual(images.totalBytes(), 1024);
assert.match(first.imageId, /^img_[a-f0-9]{64}$/);
const other = images.put(jpeg(78, 1024), 'image/jpeg');
assert.notStrictEqual(other.imageId, first.imageId, '不同字节必须得到不同 imageId');
assert.strictEqual(images.totalBytes(), 2048);
});
test('ai-images 读回与 dataUrl 一致,非法 imageId 被拒', () => {
const { images } = fresh('img-read');
const bytes = jpeg(88, 256);
const { imageId } = images.put(bytes, 'image/jpeg');
assert.ok(images.read(imageId).equals(bytes));
assert.strictEqual(images.dataUrl(imageId), `data:image/jpeg;base64,${bytes.toString('base64')}`);
for (const bad of ['', 'img_short', '../escape', 'img_' + 'g'.repeat(64), '__proto__',
`img_${'a'.repeat(64)}/../x`, null, 'pdf_' + 'a'.repeat(64)]) {
assert.throws(() => images.read(bad), /会话图像标识无效/, `imageId ${JSON.stringify(bad)} 必须被拒`);
assert.throws(() => images.dataUrl(bad), /会话图像标识无效/);
assert.throws(() => images.safeImageId(bad), /会话图像标识无效/);
}
assert.throws(() => images.read(`img_${'a'.repeat(64)}`), /会话图像不存在/);
});
test('ai-images cleanup 只删未引用的,宽限期内的新文件不删', () => {
const { images, dir } = fresh('img-cleanup');
const a = images.put(jpeg(1, 128), 'image/jpeg');
const b = images.put(jpeg(2, 128), 'image/jpeg');
const c = images.put(jpeg(3, 128), 'image/jpeg');
assert.strictEqual(images.cleanup([a.imageId]), 0,
'刚落盘的图还没被会话引用,宽限期内删掉就是删正在提交的数据');
assert.strictEqual(images.cleanup([a.imageId], { graceMs: 0 }), 2);
assert.deepStrictEqual(fs.readdirSync(imageDir(dir)), [`${a.imageId}.jpg`]);
assert.ok(images.read(a.imageId));
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 1);
assert.strictEqual(images.totalBytes(), 0);
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 0, '目录空了也不该报错');
// 白名单里的垃圾值不应意外保住任何文件
images.put(jpeg(4, 128), 'image/jpeg');
assert.strictEqual(images.cleanup(['../escape', null, '__proto__'], { graceMs: 0 }), 1);
});
test('ai-images 目录不存在时 totalBytes 与 cleanup 返回零值', () => {
const { images } = fresh('img-empty');
assert.strictEqual(images.totalBytes(), 0);
assert.strictEqual(images.cleanup([]), 0);
});
test('会话里的图像元数据被规范化,非法引用被拒', () => {
const { sessions, images } = fresh('img-meta');
const meta = sessions.create({});
const img = images.put(jpeg(99, 512), 'image/jpeg');
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', images: [{ imageId: '../escape' }]
}), /会话图像标识无效/);
assert.throws(() => sessions.appendUser(meta.id, {
text: 'q', images: [{ imageId: img.imageId, mimeType: 'image/png' }]
}), /仅支持 JPEG/);
const stored = sessions.appendUser(meta.id, {
text: 'q',
images: [
{ imageId: img.imageId, width: 100, height: 200, bytes: 512, ocrIncluded: true },
{ imageId: img.imageId, width: 100, height: 200, bytes: 512 }
]
});
assert.strictEqual(stored.images.length, 1, '同一张图重复引用应折成一条');
assert.deepStrictEqual(stored.images[0], {
imageId: img.imageId,
mimeType: 'image/jpeg',
width: 100,
height: 200,
bytes: 512,
ocrIncluded: true
});
});
// --- 对账 ---
test('orphanReport 只报书库里已不存在的条目,GLOBAL_ENTRY_ID 永不判为孤立', () => {
const { sessions } = fresh('orphan');
const inLib = sessions.create({ entryId: 'e1', title: '在库' });
const gone = sessions.create({ entryId: 'e404', title: '已删' });
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
round(sessions, gone.id, '问', '答');
const report = sessions.orphanReport(['e1']);
assert.deepStrictEqual(report.map((r) => r.sessionId), [gone.id]);
assert.strictEqual(report[0].entryId, 'e404');
assert.strictEqual(report[0].title, '已删', '报告要带标题,否则用户无法判断是否回收');
assert.strictEqual(report[0].messageCount, 2);
assert.ok(report[0].bytes > 0);
assert.ok(!report.some((r) => r.sessionId === global.id), '全局会话不绑书籍,永远不是孤立数据');
assert.ok(!report.some((r) => r.sessionId === inLib.id));
});
test('forgetMany 尊重白名单,且显式点名 GLOBAL_ENTRY_ID 也不删', () => {
const { sessions } = fresh('forget');
const keep = sessions.create({ entryId: 'e1', title: '在库' });
const dropA = sessions.create({ entryId: 'e404', title: '已删甲' });
const dropB = sessions.create({ entryId: 'e404', title: '已删乙' });
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
assert.strictEqual(sessions.forgetMany([]), 0);
assert.strictEqual(sessions.forgetMany(['e404', sessions.GLOBAL_ENTRY_ID]), 2);
const remaining = sessions.list().map((row) => row.id).sort();
assert.deepStrictEqual(remaining, [keep.id, global.id].sort(),
'在库条目与全局会话都不该被回收,误删的是用户无法找回的对话');
assert.throws(() => sessions.forgetMany(['../escape']), /会话条目 ID 无效/);
assert.strictEqual(sessions.list().length, 2, '非法输入不应造成部分删除');
});
test('rebuildIndex 返回索引形状并与 list 一致', () => {
const { sessions } = fresh('rebuild');
const a = sessions.create({ entryId: 'e1', title: '甲' });
round(sessions, a.id, '问', '答');
const idx = sessions.rebuildIndex();
assert.strictEqual(idx.version, 1);
assert.strictEqual(idx.sessions.length, 1);
assert.deepStrictEqual(Object.keys(idx.sessions[0]).sort(),
['bytes', 'entryId', 'id', 'messageCount', 'pinned', 'title', 'updatedAt'].sort());
assert.deepStrictEqual(sessions.list()[0], idx.sessions[0]);
});
test('list 返回的是副本,改动不会污染内存索引', () => {
const { sessions } = fresh('clone');
const meta = sessions.create({ title: '原标题' });
const rows = sessions.list();
rows[0].title = '被改坏';
assert.strictEqual(sessions.list()[0].title, '原标题');
assert.strictEqual(sessions.messages(meta.id).meta.title, '原标题');
});