/** * SessionSummaryService + truncateMessagesAfter 交互回归测试 * * 覆盖全量复检 #1 修复:消息截断必须同步清理 session_summaries 摘要游标, * 否则 (1) afterRowid 过滤返回空 tail 导致截断点前原文永不加载; * (2) 摘要包含被撤销的"未来"内容(因果污染)。 * * 运行要求:better-sqlite3 为 Electron ABI 构建,需 test:electron 模式 * (cross-env ELECTRON_RUN_AS_NODE=1 electron ...)执行;系统 Node 下自动跳过。 */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; let dbAvailable = true; let Database: typeof import('better-sqlite3'); try { // eslint-disable-next-line @typescript-eslint/no-require-imports Database = require('better-sqlite3'); const probe = new Database(':memory:'); probe.close(); } catch { dbAvailable = false; } describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互', () => { let db: any; let dir: string; let sessionService: any; let summaryService: any; 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()); }; beforeAll(async () => { const { SessionService } = await import('../session.service'); const { SessionSummaryService } = await import('../session-summary.service'); dir = mkdtempSync(join(tmpdir(), 'metona-summary-')); db = new Database(':memory:'); db.exec(` CREATE TABLE sessions ( id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, deleted_at INTEGER, metadata TEXT DEFAULT '{}' ); CREATE TABLE messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL ); CREATE TABLE session_summaries ( session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, summarized_until_rowid INTEGER NOT NULL, updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) ); `); db.prepare( 'INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES (?, ?, ?, ?)', ).run('s_test', Date.now(), Date.now(), 0); sessionService = new SessionService(() => db); summaryService = new SessionSummaryService( () => db, sessionService, // buildHistoryMessages 不触 adapter(仅 maybeSummarize 用),占位即可 () => null as unknown as Parameters[0], ); }); afterAll(() => { try { db?.close(); } catch { /* ignore */ } rmSync(dir, { recursive: true, force: true }); }); it('分层加载:摘要存在时返回 [摘要消息, 游标之后的原文]', () => { // 5 条消息,摘要覆盖到第 3 条(rowid 3) for (let i = 1; i <= 5; i++) { insertMessage('s_test', `m${i}`, i % 2 === 1 ? 'user' : 'assistant', `消息 ${i}`); } summaryService.saveSummary('s_test', '早期对话的滚动摘要', 3); const history = summaryService.buildHistoryMessages('s_test'); // 1 条摘要 + rowid 4、5 两条原文 expect(history.length).toBe(3); expect((history[0].content as string).startsWith('[Context Summary]')).toBe(true); expect(history[1].content).toBe('消息 4'); expect(history[2].content).toBe('消息 5'); }); it('其他会话的摘要不影响本会话(游标按 session_id 隔离)', () => { // s_other 的摘要不应影响 s_test 的分层加载结果 summaryService.saveSummary('s_other', 'x', 0); const history = summaryService.buildHistoryMessages('s_test'); // s_test 摘要(游标 3)仍生效:摘要 + rowid 4、5 expect(history.length).toBe(3); }); // ===== v0.5.4: 多轮图片记忆用例置于 describe 末尾(插入新行消耗全局自增 rowid, // 若插在中间会破坏 inclusive=false 用例对 rowid 数值的断言) ===== it('截断清理摘要(缺陷 #1 回归):游标落在删除范围内时摘要被删', () => { // 编辑重发第 2 条消息(rowid 2,inclusive)→ 删除 rowid>=2 的 4 条 // 摘要游标为 3,落在 [2, ∞) 内 → 摘要必须被清理 const truncated = sessionService.truncateMessagesAfter('s_test', 'm2', true); expect(truncated).toBe(true); // 摘要已删除 expect(summaryService.getSummary('s_test')).toBeNull(); // 只剩 rowid 1 的消息 const remaining = sessionService.getMessages('s_test'); expect(remaining.length).toBe(1); expect(remaining[0].content).toBe('消息 1'); // 分层加载退化为全量原文(无摘要注入) const history = summaryService.buildHistoryMessages('s_test'); expect(history.length).toBe(1); expect(history[0].content).toBe('消息 1'); }); it('inclusive=false:游标等于锚点时摘要保留(语义仍一致)', () => { // 重建场景:rowid 1(消息1)+ 新插入 3 条 → rowid 2-4;摘要游标 = 2 insertMessage('s_test', 'm2b', 'user', '消息 2b'); insertMessage('s_test', 'm3b', 'assistant', '消息 3b'); insertMessage('s_test', 'm4b', 'user', '消息 4b'); summaryService.saveSummary('s_test', '摘要到 rowid2', 2); // 截断 rowid > 3(保留锚点 3):游标 2 < 3+1 → 摘要保留 const truncated = sessionService.truncateMessagesAfter('s_test', 'm3b', false); expect(truncated).toBe(true); expect(summaryService.getSummary('s_test')).not.toBeNull(); // 分层加载:摘要 + rowid 3 原文(rowid 4 已删) const history = summaryService.buildHistoryMessages('s_test'); expect(history.length).toBe(2); expect(history[1].content).toBe('消息 3b'); }); it('锚点消息不存在时返回 false 且不产生副作用', () => { const before = sessionService.getMessages('s_test').length; expect(sessionService.truncateMessagesAfter('s_test', 'not_exist', true)).toBe(false); expect(sessionService.getMessages('s_test').length).toBe(before); }); /** 插入带 attachments 的用户消息(模拟 ChatInput 持久化形态) */ const insertMessageWithAttachments = ( 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('多轮图片记忆:历史消息的图片 attachments 恢复为 images 回传', () => { db.prepare(`DELETE FROM messages WHERE session_id = 's_img'`).run(); db.prepare( `INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_img', ?, ?, 0)`, ).run(Date.now(), Date.now()); insertMessageWithAttachments('s_img', 'img_m1', '第一轮带图', [ { type: 'image', preview: 'data:image/jpeg;base64,ROUND1PIC' }, ]); insertMessageWithAttachments('s_img', 'img_m2', '第二轮无图', []); insertMessageWithAttachments('s_img', 'img_m3', '第三轮带图', [ { type: 'image', preview: 'data:image/jpeg;base64,ROUND3PIC' }, { type: 'text', textContent: '文本附件不恢复为图片' }, ]); const history = summaryService.buildHistoryMessages('s_img'); // 第一轮的图片仍被恢复(多轮记忆修复点:此前历史消息 images 恒为 undefined) const round1 = history.find((m: any) => m.content === '第一轮带图'); expect(round1?.images).toEqual([{ url: 'data:image/jpeg;base64,ROUND1PIC', detail: 'auto' }]); // 第三轮两张附件只恢复 image 类型 const round3 = history.find((m: any) => m.content === '第三轮带图'); expect(round3?.images).toEqual([{ url: 'data:image/jpeg;base64,ROUND3PIC', detail: 'auto' }]); // 无图消息不带 images 字段 const round2 = history.find((m: any) => m.content === '第二轮无图'); expect(round2?.images).toBeUndefined(); }); it('多轮图片记忆:注入上限 10 张(从最新向前收集,更早的丢弃)', () => { db.prepare(`DELETE FROM messages WHERE session_id = 's_cap'`).run(); db.prepare( `INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_cap', ?, ?, 0)`, ).run(Date.now(), Date.now()); // 12 条带图消息(每条 1 张)— 上限 10 张,最早的 2 条不注入 for (let i = 1; i <= 12; i++) { insertMessageWithAttachments('s_cap', `cap_m${i}`, `第 ${i} 轮`, [ { type: 'image', preview: `data:image/jpeg;base64,PIC${i}` }, ]); } const history = summaryService.buildHistoryMessages('s_cap'); const withImages = history.filter((m: any) => m.images?.length > 0); expect(withImages).toHaveLength(10); // 保留的是第 3~12 轮(最新的 10 张) const contents = withImages.map((m: any) => m.content); expect(contents).not.toContain('第 1 轮'); expect(contents).not.toContain('第 2 轮'); expect(contents).toContain('第 12 轮'); }); it('多轮图片记忆:摘要区间内的图片不恢复(符合滚动摘要语义)', () => { db.prepare(`DELETE FROM messages WHERE session_id = 's_sum_img'`).run(); db.prepare( `INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_sum_img', ?, ?, 0)`, ).run(Date.now(), Date.now()); for (let i = 1; i <= 3; i++) { insertMessageWithAttachments('s_sum_img', `sm${i}`, `摘要区间第 ${i} 条`, [ { type: 'image', preview: `data:image/jpeg;base64,OLDPIC${i}` }, ]); } // 摘要覆盖前 3 条(含全部图片消息) const cursor = ( db.prepare(`SELECT MAX(rowid) AS r FROM messages WHERE session_id = 's_sum_img'`).get() as { r: number; } ).r; summaryService.saveSummary('s_sum_img', '早期含图对话的摘要', cursor); insertMessageWithAttachments('s_sum_img', 'sm4', '摘要后的新消息(无图)', []); const history = summaryService.buildHistoryMessages('s_sum_img'); // 摘要消息 + 1 条原文,均无图片(游标前的图片消息不在 tail 中) expect(history).toHaveLength(2); expect(history.every((m: any) => !m.images?.length)).toBe(true); }); }); /** * B-2 回归:clearMessages 必须同步删除滚动摘要 * * 缺陷根因(v0.6.4 审计):messages 使用隐式 rowid,非 AUTOINCREMENT 表全表 * DELETE 后新消息的 rowid 从 1 重新分配。若清空消息时保留 session_summaries * 的 summarized_until_rowid 游标(如 =500),则: * (1) buildHistoryMessages 的 `rowid > 500` 过滤令所有新消息落空 → * 新对话永远不进入 LLM 历史(直到重新攒满 500 条); * (2) maybeSummarize 的 `rowid > lastSummarized` 增量判定长期误报"无增量"。 */ describe.skipIf(!dbAvailable)('clearMessages × 摘要游标交互(B-2)', () => { 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, deleted_at INTEGER, metadata TEXT DEFAULT '{}' ); CREATE TABLE messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL ); CREATE TABLE session_summaries ( session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, summarized_until_rowid INTEGER NOT NULL, updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) ); `); db.prepare( 'INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES (?, ?, ?, ?)', ).run('s_clear', Date.now(), Date.now(), 0); sessionService = new SessionService(() => db); summaryService = new SessionSummaryService( () => db, sessionService, () => null as unknown as Parameters[0], ); }); afterAll(() => { try { db?.close(); } catch { /* ignore */ } }); it('清空消息后摘要与游标一并清除;rowid 重分配后新对话正常进入历史', () => { const ins = (id: string, content: string): void => { db.prepare( `INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, 'user', ?, ?)`, ).run(id, 's_clear', content, Date.now()); }; // 阶段一:5 条历史 + 摘要游标远超当前 rowid(模拟旧会话曾积累大量已摘要消息: // 用户在长会话里攒到 summarized_until_rowid=500 后执行"清空消息") for (let i = 1; i <= 5; i++) ins(`old_m${i}`, `旧消息 ${i}`); const maxRowIdBefore = ( db.prepare(`SELECT MAX(rowid) AS r FROM messages WHERE session_id='s_clear'`).get() as { r: number; } ).r; expect(maxRowIdBefore).toBe(5); summaryService.saveSummary('s_clear', '覆盖全部旧消息的摘要', 500); // 复现缺陷前提(且暴露缺陷真实严重度):rowid=6 < 游标 500, // 新消息全部被 `rowid > 500` 过滤落空;而摘要注入又要求"存在 tail"才生效 // (buildHistoryMessages: existing && messages.length > 0)—— 结果历史恒为空: // 该会话从此刻起 LLM 完全失忆,直到新消息 rowid 涨过 500。 ins('transition_m', '过渡期新消息'); expect(summaryService.buildHistoryMessages('s_clear').length).toBe(0); // 阶段二:用户点击"清空消息" // v0.7.2 A1: clearMessages 语义改为 void(成功判定是"操作完成"而非"有行被删除", // 空会话清空同样成功 —— IPC 层据此统一返回 success:true) expect(() => sessionService.clearMessages('s_clear')).not.toThrow(); expect(sessionService.getMessages('s_clear')).toHaveLength(0); // 核心契约 1:摘要记录被同步删除(原实现此处残留 cursor=5) expect(summaryService.getSummary('s_clear')).toBeNull(); // 核心契约 2:全表 DELETE 后 rowid 从 1 重排(SQLite 非 AUTOINCREMENT 行为), // 若摘要未删,这些 rowid=1..n 的新消息将永远过不了 `rowid > 500` 过滤。 for (let i = 1; i <= 2; i++) ins(`new_m${i}`, `新对话 ${i}`); const historyAfterClear = summaryService.buildHistoryMessages('s_clear'); expect(historyAfterClear).toHaveLength(2); expect(historyAfterClear[0].content).toBe('新对话 1'); expect(historyAfterClear[1].content).toBe('新对话 2'); }); }); /** * F-3 回归:session_summaries 级联删除 * * 缺陷:该表此前无 FOREIGN KEY ON DELETE CASCADE —— 删除会话后摘要残留。 * 迁移 8 重建表补级联;此测试验证目标 schema 的行为契约: * DELETE sessions 必须级联清除对应摘要(真实库由 DatabaseService 开启 foreign_keys pragma)。 */ describe.skipIf(!dbAvailable)('session_summaries 级联删除(F-3)', () => { let db: any; beforeAll(() => { db = new Database(':memory:'); // 与 DatabaseService.initialize 一致:开启外键约束(级联依赖此开关) db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE sessions ( id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, deleted_at INTEGER, metadata TEXT DEFAULT '{}' ); CREATE TABLE session_summaries ( session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, summarized_until_rowid INTEGER NOT NULL, updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); `); }); afterAll(() => { db?.close(); }); it('外键声明存在且为 CASCADE(迁移 8 后的目标 schema)', () => { 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('删除会话后摘要级联清除(不再残留孤儿数据)', () => { const now = Date.now(); db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('s_cascade', ?, ?)`).run( now, now, ); db.prepare( `INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at) VALUES ('s_cascade', '将被级联删除的摘要', 99, ?)`, ).run(now); db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('s_keep', ?, ?)`).run( now, now, ); db.prepare( `INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at) VALUES ('s_keep', '保留的摘要', 50, ?)`, ).run(now); db.prepare(`DELETE FROM sessions WHERE id = 's_cascade'`).run(); const remaining = db.prepare(`SELECT session_id FROM session_summaries`).all() as Array<{ session_id: string; }>; 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; const createSuite = async (): Promise => { 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, deleted_at INTEGER, metadata TEXT DEFAULT '{}' ); CREATE TABLE messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL ); CREATE 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, deleted_at INTEGER, metadata TEXT DEFAULT '{}' ); CREATE TABLE messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL ); CREATE 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[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, deleted_at INTEGER, metadata TEXT DEFAULT '{}' ); CREATE TABLE messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL ); CREATE 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[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(); }); });