/** * truncateMessagesAfter × TRACE metadata 联动测试(v0.7.3 P1-2) * * 根治契约:编辑重发/重新生成截断消息时,sessions.metadata 中的 traceSteps * 必须按 startedAt <= 锚点消息 created_at 同步过滤,否则 Trace 面板出现 * "幽灵步骤"(v0.7.2 A1 只修了 /clear 路径的 metadata 残留)。 * * 运行要求:better-sqlite3 为 Electron ABI 构建,需 test: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() }, })); 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)('truncateMessagesAfter × TRACE metadata 过滤(P1-2)', () => { let db: import('better-sqlite3').Database; // 测试域宽松类型:SessionService 依赖 Electron ABI 的 better-sqlite3 实例, // 直接持有真实实例即可(同 session-summary.test.ts 的既有写法) // eslint(next-line 无需禁用:测试文件未启用 no-explicit-any) let sessionService: any; beforeAll(async () => { const { SessionService } = await import('../session.service'); db = new Database(':memory:'); db.exec(` CREATE TABLE sessions ( id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}' ); CREATE TABLE messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL ); CREATE 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 (?, ?, ?, 0)', ).run('s_trace', Date.now(), Date.now()); sessionService = new SessionService(() => db); }); afterAll(() => { try { db?.close(); } catch { /* ignore */ } }); const insertMessage = (id: string, content: string, createdAt: number): number => { db.prepare( `INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, 'user', ?, ?)`, ).run(id, 's_trace', content, createdAt); const row = db.prepare('SELECT rowid AS rid FROM messages WHERE id = ?').get(id) as { rid: number; }; return row.rid; }; const saveMetadata = (steps: Array<{ runId?: string; startedAt: number }>): void => { db.prepare('UPDATE sessions SET metadata = ? WHERE id = ?').run( JSON.stringify({ traceSteps: steps, tokenUsage: { totalTokens: 10 } }), 's_trace', ); }; const loadMetadataSteps = (): Array<{ runId?: string; startedAt: number }> => { const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get('s_trace') as { metadata: string; }; const parsed = JSON.parse(row.metadata) as { traceSteps?: Array<{ runId?: string; startedAt: number }>; }; return parsed.traceSteps ?? []; }; it('截断后:晚于锚点消息的 traceSteps 被过滤,早于/等于的保留', () => { const anchorTs = 1_000_000; const anchorId = 'm_anchor'; insertMessage('m_before', '更早的消息', anchorTs - 10_000); insertMessage(anchorId, '锚点消息', anchorTs); insertMessage('m_after', '锚点后的消息', anchorTs + 10_000); saveMetadata([ { runId: 'r_before', startedAt: anchorTs - 9_000 }, { runId: 'r_anchor', startedAt: anchorTs }, // 锚点触发的 run(将被截) { runId: 'r_after', startedAt: anchorTs + 11_000 }, ]); const truncated = sessionService.truncateMessagesAfter('s_trace', anchorId, true); expect(truncated).toBe(true); const kept = loadMetadataSteps(); expect(kept.map((s) => s.runId)).toEqual(['r_before']); }); it('metadata 缺失/损坏时不阻断截断主流程', () => { db.prepare("UPDATE sessions SET metadata = 'not-json' WHERE id = ?").run('s_trace'); const anchorTs = 2_000_000; const anchorId = 'm2'; insertMessage(anchorId, '锚点消息 2', anchorTs); insertMessage('m2_after', '锚点 2 之后的消息', anchorTs + 1000); expect(() => sessionService.truncateMessagesAfter('s_trace', anchorId, true)).not.toThrow(); expect(sessionService.truncateMessagesAfter('s_trace', anchorId, true)).toBe(false); // 已被上一句删过,无行可删 }); it('锚点不存在时返回 false 且不触碰 metadata', () => { saveMetadata([{ runId: 'r_keep', startedAt: Date.now() }]); expect(sessionService.truncateMessagesAfter('s_trace', 'm_missing', true)).toBe(false); expect(loadMetadataSteps()).toHaveLength(1); }); });