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'); } // 最小可解码 JPEG:SOI + 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('.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, '原标题'); });