/** * 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, 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); }); 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); }); });