import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { DatabaseManager } from '../../src/store/db.js'; import { indexSession, indexAllSessions, indexChangedSessions, getSessionStats, countSessionFiles, needsBackfill, touchBackfillTimestamp, LAST_SESSION_BACKFILL_KEY, indexCurrentSession, indexLiveSession, parseSessionManagerSnapshot, truncateMessageContent, upsertSessionFileMetadata, pruneEphemeralReviewSessions, pruneOldSessions, } from '../../src/store/session-indexer.js'; import { DEFAULT_MAX_MESSAGE_CONTENT_LENGTH } from '../../src/constants.js'; import { parseSessionFile, type ParsedSession } from '../../src/store/session-parser.js'; describe('session-indexer', () => { let tmpDir: string; let dbManager: DatabaseManager; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'indexer-test-')); dbManager = new DatabaseManager(tmpDir); }); afterEach(() => { dbManager.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); function createTestSession(overrides: Partial = {}): ParsedSession { const id = overrides.id ?? 'session-1'; return { id, project: 'test-project', cwd: '/test', startedAt: '2026-05-03T00:00:00Z', endedAt: null, messages: [ { id: `${id}-msg-1`, role: 'user', content: 'Hello', timestamp: '2026-05-03T00:01:00Z' }, { id: `${id}-msg-2`, role: 'assistant', content: 'Hi there!', timestamp: '2026-05-03T00:01:30Z', toolCalls: ['read'] }, ], ...overrides, }; } describe('indexSession', () => { it('should index a session and its messages', () => { const session = createTestSession(); const result = indexSession(dbManager, session); assert.strictEqual(result.sessionId, 'session-1'); assert.strictEqual(result.messagesIndexed, 2); assert.strictEqual(result.skipped, false); // Verify in database const db = dbManager.getDb(); const dbSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get('session-1') as Record; assert.strictEqual(dbSession.project, 'test-project'); assert.strictEqual(dbSession.message_count, 2); const messages = db.prepare('SELECT * FROM messages WHERE session_id = ?').all('session-1') as Record[]; assert.strictEqual(messages.length, 2); assert.strictEqual(messages[0].role, 'user'); assert.strictEqual(messages[1].role, 'assistant'); }); it('should store tool_calls as JSON', () => { const session = createTestSession(); indexSession(dbManager, session); const db = dbManager.getDb(); const msg = db.prepare('SELECT tool_calls FROM messages WHERE id = ?').get('session-1-msg-2') as { tool_calls: string | null }; assert.ok(msg.tool_calls); assert.deepStrictEqual(JSON.parse(msg.tool_calls), ['read']); }); it('should cap oversized message content while retaining both ends', () => { const content = `begin-${'x'.repeat(DEFAULT_MAX_MESSAGE_CONTENT_LENGTH)}-end`; const session = createTestSession({ messages: [{ id: 'oversized-message', role: 'assistant', content, timestamp: '2026-05-03T00:01:00Z' }], }); indexSession(dbManager, session); const stored = dbManager.getDb().prepare('SELECT content FROM messages WHERE id = ?').get('oversized-message') as { content: string }; assert.ok(stored.content.length <= DEFAULT_MAX_MESSAGE_CONTENT_LENGTH); assert.match(stored.content, /^begin-/); assert.match(stored.content, /-end$/); assert.match(stored.content, /truncated, \d+ chars total/); assert.strictEqual(truncateMessageContent('short content'), 'short content'); }); it('should skip already-indexed sessions with no new messages', () => { const session = createTestSession(); const result1 = indexSession(dbManager, session); assert.strictEqual(result1.skipped, false); const result2 = indexSession(dbManager, session); assert.strictEqual(result2.skipped, true); assert.strictEqual(result2.messagesIndexed, 0); }); it('should append missing messages for an already-indexed resumed session', () => { const session = createTestSession(); indexSession(dbManager, session); const resumed = createTestSession({ messages: [ ...session.messages, { id: 'session-1-msg-3', role: 'user', content: 'Resumed later', timestamp: '2026-05-03T00:02:00Z' }, ], }); const result = indexSession(dbManager, resumed); assert.strictEqual(result.skipped, false); assert.strictEqual(result.messagesIndexed, 1); assert.strictEqual(dbManager.getStats().sessions, 1); assert.strictEqual(dbManager.getStats().messages, 3); const dbSession = dbManager.getDb().prepare('SELECT message_count FROM sessions WHERE id = ?').get('session-1') as { message_count: number }; assert.strictEqual(dbSession.message_count, 3); }); it('should handle sessions with no messages', () => { const session = createTestSession({ messages: [] }); const result = indexSession(dbManager, session); assert.strictEqual(result.messagesIndexed, 0); assert.strictEqual(result.skipped, false); }); }); describe('indexAllSessions', () => { it('should index all JSONL files from disk', () => { // Create mock session directory structure const sessionsDir = path.join(tmpDir, 'sessions'); const projDir = path.join(sessionsDir, 'test-project'); fs.mkdirSync(projDir, { recursive: true }); // Write a valid JSONL file const lines = [ JSON.stringify({ type: 'session', id: 's1', timestamp: '2026-05-03T00:00:00Z', cwd: '/test' }), JSON.stringify({ type: 'message', id: 'm1', parentId: null, timestamp: '2026-05-03T00:01:00Z', message: { role: 'user', content: [{ type: 'text', text: 'Hello' }], timestamp: Date.now() }, }), ]; fs.writeFileSync(path.join(projDir, 'session1.jsonl'), lines.join('\n')); const result = indexAllSessions(dbManager, sessionsDir); assert.strictEqual(result.sessionsProcessed, 1); assert.strictEqual(result.sessionsIndexed, 1); assert.strictEqual(result.messagesIndexed, 1); assert.strictEqual(result.errors.length, 0); }); it('should skip already-indexed sessions on re-run', () => { const sessionsDir = path.join(tmpDir, 'sessions'); const projDir = path.join(sessionsDir, 'test-project'); fs.mkdirSync(projDir, { recursive: true }); const lines = [ JSON.stringify({ type: 'session', id: 's1', timestamp: '2026-05-03T00:00:00Z', cwd: '/test' }), JSON.stringify({ type: 'message', id: 'm1', parentId: null, timestamp: '2026-05-03T00:01:00Z', message: { role: 'user', content: [{ type: 'text', text: 'Hello' }], timestamp: Date.now() }, }), ]; fs.writeFileSync(path.join(projDir, 'session1.jsonl'), lines.join('\n')); // First run const result1 = indexAllSessions(dbManager, sessionsDir); assert.strictEqual(result1.sessionsIndexed, 1); // Second run — should skip const result2 = indexAllSessions(dbManager, sessionsDir); assert.strictEqual(result2.sessionsSkipped, 1); assert.strictEqual(result2.sessionsIndexed, 0); }); it('should handle invalid JSONL files gracefully', () => { const sessionsDir = path.join(tmpDir, 'sessions'); const projDir = path.join(sessionsDir, 'test-project'); fs.mkdirSync(projDir, { recursive: true }); // Invalid file (no session entry) fs.writeFileSync(path.join(projDir, 'invalid.jsonl'), '{"type":"message","id":"m1"}'); const result = indexAllSessions(dbManager, sessionsDir); assert.strictEqual(result.sessionsProcessed, 1); assert.strictEqual(result.errors.length, 1); }); it('should handle empty sessions directory', () => { const sessionsDir = path.join(tmpDir, 'empty-sessions'); fs.mkdirSync(sessionsDir); const result = indexAllSessions(dbManager, sessionsDir); assert.strictEqual(result.sessionsProcessed, 0); assert.strictEqual(result.sessionsIndexed, 0); }); it('should handle non-existent sessions directory', () => { const result = indexAllSessions(dbManager, '/nonexistent/path'); assert.strictEqual(result.sessionsProcessed, 0); }); it('skips files outside the retention window and reports expiredSkipped', () => { const sessionsDir = path.join(tmpDir, 'sessions'); const projDir = path.join(sessionsDir, 'test-project'); fs.mkdirSync(projDir, { recursive: true }); const write = (fileName: string, sessionId: string) => { const lines = [ JSON.stringify({ type: 'session', id: sessionId, timestamp: '2026-05-03T00:00:00Z', cwd: '/test' }), JSON.stringify({ type: 'message', id: `${sessionId}-m1`, parentId: null, timestamp: '2026-05-03T00:01:00Z', message: { role: 'user', content: [{ type: 'text', text: 'Hello' }], timestamp: Date.now() }, }), ]; fs.writeFileSync(path.join(projDir, fileName), lines.join('\n')); }; write('recent.jsonl', 'recent'); write('expired.jsonl', 'expired'); // Age the second file beyond the retention window. const old = new Date('2026-01-01T00:00:00Z'); fs.utimesSync(path.join(projDir, 'expired.jsonl'), old, old); const cutoff = Date.parse('2026-03-01T00:00:00Z'); const result = indexAllSessions(dbManager, sessionsDir, undefined, cutoff); assert.strictEqual(result.sessionsIndexed, 1); assert.strictEqual(result.expiredSkipped, 1); assert.strictEqual(dbManager.getStats().sessions, 1); // Without a cutoff the same directory indexes both files. const result2 = indexAllSessions(dbManager, sessionsDir); assert.strictEqual(result2.sessionsIndexed, 1); assert.strictEqual(result2.expiredSkipped, undefined); assert.strictEqual(dbManager.getStats().sessions, 2); }); }); describe('indexChangedSessions', () => { function writeJsonlSession(filePath: string, sessionId: string, messageIds = [`${sessionId}-m1`]): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const lines = [ JSON.stringify({ type: 'session', id: sessionId, timestamp: '2026-05-03T00:00:00Z', cwd: `/test/${sessionId}` }), ...messageIds.map((id, index) => JSON.stringify({ type: 'message', id, parentId: null, timestamp: `2026-05-03T00:0${index + 1}:00Z`, message: { role: 'user', content: [{ type: 'text', text: `Hello ${id}` }], timestamp: Date.now() }, })), ]; fs.writeFileSync(filePath, lines.join('\n')); } it('skips unchanged files using stored size and mtime metadata without parsing them', () => { const sessionsDir = path.join(tmpDir, 'sessions'); const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl'); writeJsonlSession(filePath, 's1'); indexAllSessions(dbManager, sessionsDir); const result = indexChangedSessions(dbManager, sessionsDir); assert.strictEqual(result.sessionsProcessed, 0); assert.strictEqual(result.sessionsSkipped, 1); assert.strictEqual(result.errors.length, 0); }); it('indexes changed files and appends newly persisted messages', () => { const sessionsDir = path.join(tmpDir, 'sessions'); const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl'); writeJsonlSession(filePath, 's1', ['s1-m1']); indexAllSessions(dbManager, sessionsDir); writeJsonlSession(filePath, 's1', ['s1-m1', 's1-m2']); const result = indexChangedSessions(dbManager, sessionsDir); assert.strictEqual(result.sessionsProcessed, 1); assert.strictEqual(result.sessionsIndexed, 1); assert.strictEqual(result.messagesIndexed, 1); assert.strictEqual(dbManager.getStats().messages, 2); }); it('parses existing sessions without file metadata and appends missed messages', () => { const sessionsDir = path.join(tmpDir, 'sessions'); const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl'); indexSession(dbManager, createTestSession({ id: 's1', messages: [ { id: 's1-m1', role: 'user', content: 'Hello s1-m1', timestamp: '2026-05-03T00:01:00Z' }, ], })); writeJsonlSession(filePath, 's1', ['s1-m1', 's1-m2']); const result = indexChangedSessions(dbManager, sessionsDir); assert.strictEqual(result.sessionsProcessed, 1); assert.strictEqual(result.sessionsIndexed, 1); assert.strictEqual(result.messagesIndexed, 1); assert.strictEqual(dbManager.getStats().messages, 2); }); it('caps parsed files during startup incremental backfill', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(path.join(sessionsDir, 'project-a', 's1.jsonl'), 's1'); writeJsonlSession(path.join(sessionsDir, 'project-a', 's2.jsonl'), 's2'); const result = indexChangedSessions(dbManager, sessionsDir, { maxFilesToIndex: 1 }); assert.strictEqual(result.sessionsProcessed, 1); assert.strictEqual(result.reachedLimit, true); assert.strictEqual(dbManager.getStats().sessions, 1); }); it('processes the most recently modified changed files first when the cap is reached', () => { const sessionsDir = path.join(tmpDir, 'sessions'); // Write an older file first, then a newer one. With newest-first ordering // the newer file must be indexed even when the cap only allows one file. const olderPath = path.join(sessionsDir, 'project-a', 'older.jsonl'); const newerPath = path.join(sessionsDir, 'project-a', 'newer.jsonl'); writeJsonlSession(olderPath, 'older'); // Ensure a measurable mtime gap (some filesystems have coarse mtime resolution). const past = new Date(Date.now() - 60_000); fs.utimesSync(olderPath, past, past); writeJsonlSession(newerPath, 'newer'); const result = indexChangedSessions(dbManager, sessionsDir, { maxFilesToIndex: 1 }); assert.strictEqual(result.sessionsProcessed, 1); assert.strictEqual(result.reachedLimit, true); assert.strictEqual(dbManager.getStats().sessions, 1); // The newer file should be the one indexed. const indexed = dbManager.getDb().prepare('SELECT id FROM sessions').all() as { id: string }[]; assert.deepStrictEqual(indexed.map((r) => r.id), ['newer']); }); }); describe('current session indexing helpers', () => { function writeSessionFile(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const lines = [ JSON.stringify({ type: 'session', id: 'file-session-1', timestamp: '2026-05-03T00:00:00Z', cwd: '/work/file-project' }), JSON.stringify({ type: 'message', id: 'file-entry-1', parentId: null, timestamp: '2026-05-03T00:01:00Z', message: { role: 'user', content: [{ type: 'text', text: 'from persisted file' }], timestamp: Date.now() }, }), ]; fs.writeFileSync(filePath, lines.join('\n')); } function createSessionManagerSnapshot(entries: unknown[] = []) { return { getHeader: () => ({ id: 'live-session-1', timestamp: '2026-05-03T00:00:00Z', cwd: '/work/live-project' }), getEntries: () => entries, }; } it('parseSessionManagerSnapshot converts current session entries into ParsedSession', () => { const snapshot = createSessionManagerSnapshot([ { type: 'message', id: 'entry-1', timestamp: '2026-05-03T00:01:00Z', message: { role: 'user', content: 'Hello live session' }, }, { type: 'message', id: 'entry-2', timestamp: '2026-05-03T00:02:00Z', message: { role: 'assistant', content: [{ type: 'text', text: 'Hi' }, { type: 'toolCall', name: 'read' }] }, }, { type: 'message', id: 'entry-3', timestamp: '2026-05-03T00:03:00Z', message: { role: 'toolResult', content: [{ type: 'text', text: 'tool output is not indexed by current schema' }] }, }, ]); const parsed = parseSessionManagerSnapshot(snapshot); assert.ok(parsed); assert.strictEqual(parsed.id, 'live-session-1'); assert.strictEqual(parsed.project, 'live-project'); assert.strictEqual(parsed.messages.length, 2); assert.deepStrictEqual(parsed.messages[1].toolCalls, ['read']); }); it('parseSessionManagerSnapshot skips embedded tool result content', () => { const parsed = parseSessionManagerSnapshot(createSessionManagerSnapshot([ { type: 'message', id: 'entry-1', timestamp: '2026-05-03T00:01:00Z', message: { role: 'assistant', content: [ { type: 'text', text: 'Summary of the result.' }, { type: 'tool_result', content: [{ type: 'text', text: 'unbounded output' }] }, ], }, }, ])); assert.ok(parsed); assert.strictEqual(parsed.messages[0].content, 'Summary of the result.'); }); it('indexLiveSession prefers the persisted JSONL file when available', () => { const filePath = path.join(tmpDir, 'sessions', 'project', 'file-session.jsonl'); writeSessionFile(filePath); const snapshot = { getHeader: () => ({ id: 'stale-memory-session', timestamp: '2026-05-03T00:00:00Z', cwd: '/work/stale' }), getEntries: () => [], getSessionFile: () => filePath, }; const result = indexLiveSession(dbManager, snapshot); assert.ok(result); assert.strictEqual(result.sessionId, 'file-session-1'); assert.strictEqual(result.messagesIndexed, 1); const indexed = dbManager.getDb().prepare('SELECT id, cwd FROM sessions WHERE id = ?').get('file-session-1') as { id: string; cwd: string }; assert.strictEqual(indexed.cwd, '/work/file-project'); }); it('does not index a session without persisted file provenance', () => { const result = indexLiveSession(dbManager, { getHeader: () => ({ id: 'ephemeral-review', timestamp: '2026-05-03T00:00:00Z', cwd: '/tmp/review' }), getEntries: () => [{ type: 'message', id: 'review-message', timestamp: '2026-05-03T00:01:00Z', message: { role: 'user', content: [{ type: 'text', text: 'review prompt' }] }, }], getSessionFile: () => undefined, }); assert.strictEqual(result, null); assert.strictEqual(dbManager.getStats().sessions, 0); assert.strictEqual(dbManager.getStats().messages, 0); }); it('removes existing file-less background review sessions', () => { indexSession(dbManager, { id: 'old-background-review', project: 'review', cwd: '/tmp/review', startedAt: '2026-05-03T00:00:00Z', endedAt: null, messages: [{ id: 'review-message', role: 'user', content: '\nReview the conversation above.', timestamp: '2026-05-03T00:01:00Z', }], }); assert.strictEqual(pruneEphemeralReviewSessions(dbManager), 1); assert.strictEqual(dbManager.getStats().sessions, 0); assert.strictEqual(dbManager.getStats().messages, 0); }); it('indexCurrentSession indexes missing live messages idempotently', () => { const entries = [ { type: 'message', id: 'entry-1', timestamp: '2026-05-03T00:01:00Z', message: { role: 'user', content: 'Hello live session' }, }, ]; const snapshot = createSessionManagerSnapshot(entries); const result1 = indexCurrentSession(dbManager, snapshot); assert.ok(result1); assert.strictEqual(result1.messagesIndexed, 1); assert.strictEqual(dbManager.getStats().sessions, 1); assert.strictEqual(dbManager.getStats().messages, 1); entries.push({ type: 'message', id: 'entry-2', timestamp: '2026-05-03T00:02:00Z', message: { role: 'assistant', content: [{ type: 'text', text: 'Hi again' }] }, }); const result2 = indexCurrentSession(dbManager, snapshot); assert.ok(result2); assert.strictEqual(result2.messagesIndexed, 1); assert.strictEqual(dbManager.getStats().sessions, 1); assert.strictEqual(dbManager.getStats().messages, 2); const result3 = indexCurrentSession(dbManager, snapshot); assert.ok(result3); assert.strictEqual(result3.skipped, true); assert.strictEqual(result3.messagesIndexed, 0); }); }); describe('backfill metadata helpers', () => { function writeJsonlSession(sessionsDir: string, projectDir: string, sessionId: string): void { const projDir = path.join(sessionsDir, projectDir); fs.mkdirSync(projDir, { recursive: true }); const lines = [ JSON.stringify({ type: 'session', id: sessionId, timestamp: '2026-05-03T00:00:00Z', cwd: `/test/${projectDir}` }), JSON.stringify({ type: 'message', id: `${sessionId}-m1`, parentId: null, timestamp: '2026-05-03T00:01:00Z', message: { role: 'user', content: [{ type: 'text', text: 'Hello' }], timestamp: Date.now() }, }), ]; fs.writeFileSync(path.join(projDir, `${sessionId}.jsonl`), lines.join('\n')); } it('countSessionFiles counts JSONL files in session project directories', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(sessionsDir, 'project-a', 's1'); writeJsonlSession(sessionsDir, 'project-b', 's2'); fs.writeFileSync(path.join(sessionsDir, 'project-b', 'notes.txt'), 'not a session'); assert.strictEqual(countSessionFiles(sessionsDir), 2); }); it('needsBackfill is true when session file count exceeds indexed sessions', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(sessionsDir, 'project-a', 's1'); assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true); }); it('needsBackfill is false when counts match and timestamp is recent', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(sessionsDir, 'project-a', 's1'); indexAllSessions(dbManager, sessionsDir); touchBackfillTimestamp(dbManager, new Date('2026-05-03T00:30:00Z')); assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), false); }); it('needsBackfill is true when file metadata changes even with a recent timestamp', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(sessionsDir, 'project-a', 's1'); indexAllSessions(dbManager, sessionsDir); touchBackfillTimestamp(dbManager, new Date('2026-05-03T00:30:00Z')); fs.appendFileSync(path.join(sessionsDir, 'project-a', 's1.jsonl'), '\n' + JSON.stringify({ type: 'message', id: 's1-m2', parentId: null, timestamp: '2026-05-03T00:02:00Z', message: { role: 'user', content: [{ type: 'text', text: 'Hello again' }], timestamp: Date.now() }, })); assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true); }); it('needsBackfill is true for existing sessions without file metadata even with a recent timestamp', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(sessionsDir, 'project-a', 's1'); indexSession(dbManager, createTestSession({ id: 's1', messages: [] })); touchBackfillTimestamp(dbManager, new Date('2026-05-03T00:30:00Z')); assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true); }); it('needsBackfill is true when timestamp is missing or older than 24 hours', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(sessionsDir, 'project-a', 's1'); indexAllSessions(dbManager, sessionsDir); assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true); touchBackfillTimestamp(dbManager, new Date('2026-05-01T00:00:00Z')); assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true); }); it('needsBackfill returns no work when retention is enabled and every file is expired, even without a backfill timestamp', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(sessionsDir, 'project-a', 's1'); const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl'); // Age the file beyond the retention window. const old = new Date('2026-01-01T00:00:00Z'); fs.utimesSync(filePath, old, old); const now = new Date('2026-05-03T01:00:00Z'); const cutoff = Date.parse('2026-03-01T00:00:00Z'); assert.ok(old.getTime() < cutoff, 'sanity: the file must be outside the window'); // Fresh DB: no indexed rows and no backfill timestamp, so the periodic // timestamp check alone would demand a backfill. With retention enabled // and no retained files, there is no work to do — this must return // false instead of scheduling an empty backfill on every startup. assert.strictEqual(needsBackfill(dbManager, sessionsDir, now, cutoff), false); }); it('needsBackfill ignores expired files instead of using a blind file-count shortcut', () => { const sessionsDir = path.join(tmpDir, 'sessions'); // Two files on disk, zero indexed rows: a file-count-based shortcut // would return true here. With retention enabled and every file // expired, there is nothing to backfill. writeJsonlSession(sessionsDir, 'project-a', 's1'); writeJsonlSession(sessionsDir, 'project-b', 's2'); const old = new Date('2026-01-01T00:00:00Z'); fs.utimesSync(path.join(sessionsDir, 'project-a', 's1.jsonl'), old, old); fs.utimesSync(path.join(sessionsDir, 'project-b', 's2.jsonl'), old, old); const cutoff = Date.parse('2026-03-01T00:00:00Z'); assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z'), cutoff), false); // A retained file with no stored metadata still demands a backfill, // even though the file count alone (2 files, 0 rows) is unchanged. const recent = new Date('2026-04-15T00:00:00Z'); fs.utimesSync(path.join(sessionsDir, 'project-b', 's2.jsonl'), recent, recent); assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z'), cutoff), true); }); it('needsBackfill still schedules when a retained file exists without a backfill timestamp', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(sessionsDir, 'project-a', 's1'); const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl'); // File stays inside the retention window. const recent = new Date('2026-04-15T00:00:00Z'); fs.utimesSync(filePath, recent, recent); const now = new Date('2026-05-03T01:00:00Z'); const cutoff = Date.parse('2026-03-01T00:00:00Z'); assert.ok(recent.getTime() >= cutoff, 'sanity: the file must be inside the window'); assert.strictEqual(needsBackfill(dbManager, sessionsDir, now, cutoff), true); }); it('needsBackfill keeps the periodic check active when retention is disabled', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(sessionsDir, 'project-a', 's1'); const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl'); // An old file is still eligible when retention is disabled (cutoff 0). const old = new Date('2026-01-01T00:00:00Z'); fs.utimesSync(filePath, old, old); assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true); }); it('touchBackfillTimestamp upserts the metadata row', () => { touchBackfillTimestamp(dbManager, new Date('2026-05-03T00:00:00Z')); touchBackfillTimestamp(dbManager, new Date('2026-05-03T01:00:00Z')); const row = dbManager.getDb().prepare('SELECT value FROM extension_metadata WHERE key = ?').get(LAST_SESSION_BACKFILL_KEY) as { value: string }; assert.strictEqual(row.value, '2026-05-03T01:00:00.000Z'); }); it('upsertSessionFileMetadata keeps stored metadata in sync after a session file is appended', () => { // Mirrors the session_shutdown path: index the session, then upsert the // file metadata for the final on-disk state. A subsequent // indexChangedSessions pass must skip the file instead of re-parsing it. const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSession(sessionsDir, 'project-a', 's1'); const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl'); indexAllSessions(dbManager, sessionsDir); // Simulate Pi appending the closing entry on shutdown. fs.appendFileSync(filePath, '\n' + JSON.stringify({ type: 'message', id: 's1-m2', parentId: null, timestamp: '2026-05-03T00:02:00Z', message: { role: 'user', content: [{ type: 'text', text: 'Hello again' }], timestamp: Date.now() }, })); const session = parseSessionFile(filePath); indexSession(dbManager, session); upsertSessionFileMetadata(dbManager, filePath, session.id); const result = indexChangedSessions(dbManager, sessionsDir); assert.strictEqual(result.sessionsProcessed, 0); assert.strictEqual(result.sessionsSkipped, 1); assert.strictEqual(result.reachedLimit, undefined); }); }); describe('retention and backfill alignment', () => { /** Write a session JSONL file and force its mtime to `ms` (ms epoch). */ function writeJsonlSessionAt(sessionsDir: string, sessionId: string, ms: number): string { const filePath = path.join(sessionsDir, 'project-a', `${sessionId}.jsonl`); fs.mkdirSync(path.dirname(filePath), { recursive: true }); const lines = [ JSON.stringify({ type: 'session', id: sessionId, timestamp: new Date(ms).toISOString(), cwd: `/test/${sessionId}` }), JSON.stringify({ type: 'message', id: `${sessionId}-m1`, parentId: null, timestamp: new Date(ms + 1000).toISOString(), message: { role: 'user', content: [{ type: 'text', text: `Hello ${sessionId}` }], timestamp: Date.now() } }), ]; fs.writeFileSync(filePath, lines.join('\n')); fs.utimesSync(filePath, new Date(ms), new Date(ms)); return filePath; } const DAY = 24 * 60 * 60 * 1000; const now = () => Date.now(); const cutoffFor = (days: number) => now() - days * DAY; it('does not re-index a session file outside the retention cutoff after pruning', () => { const sessionsDir = path.join(tmpDir, 'sessions'); // A file last modified 60 days ago; retention window of 30 days => expired. writeJsonlSessionAt(sessionsDir, 's1', cutoffFor(60)); const cutoff = cutoffFor(30); // Fresh index (no cutoff) mirrors the pre-retention state, then retention // pruning removes the session row; the on-disk file stays. indexAllSessions(dbManager, sessionsDir); const pruned = pruneOldSessions(dbManager, 30); assert.strictEqual(pruned.sessionsRemoved, 1); assert.strictEqual(dbManager.getStats().sessions, 0); // A later startup backfill, given the same retention cutoff, must NOT // re-queue the expired file -- otherwise the pruned session returns. const result = indexChangedSessions(dbManager, sessionsDir, { retentionCutoffMs: cutoff }); assert.strictEqual(result.sessionsProcessed, 0); assert.strictEqual(result.sessionsIndexed, 0); assert.strictEqual(result.sessionsSkipped, 0); assert.strictEqual(dbManager.getStats().sessions, 0); }); it('does not schedule a startup backfill when only expired files remain', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSessionAt(sessionsDir, 's1', cutoffFor(60)); const cutoff = cutoffFor(30); indexAllSessions(dbManager, sessionsDir); pruneOldSessions(dbManager, 30); touchBackfillTimestamp(dbManager); // With every file outside the window, needsBackfill must be false so no // backfill is scheduled on the next startup (no repeated re-indexing). assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date(), cutoff), false); }); it('still indexes files within the retention cutoff even when others are expired', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSessionAt(sessionsDir, 'old', cutoffFor(60)); writeJsonlSessionAt(sessionsDir, 'fresh', now()); const cutoff = cutoffFor(30); const result = indexChangedSessions(dbManager, sessionsDir, { retentionCutoffMs: cutoff }); // Only the fresh file is eligible; the expired one is skipped. assert.strictEqual(result.sessionsProcessed, 1); assert.strictEqual(result.sessionsIndexed, 1); const stats = getSessionStats(dbManager); assert.deepStrictEqual(stats.projects[0].sessions, 1); // only the retained session assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date(), cutoff), true); }); it('prunes only sessions whose file mtime is outside the retention window', () => { const sessionsDir = path.join(tmpDir, 'sessions'); writeJsonlSessionAt(sessionsDir, 'old', cutoffFor(60)); writeJsonlSessionAt(sessionsDir, 'recent', now()); indexAllSessions(dbManager, sessionsDir); const pruned = pruneOldSessions(dbManager, 30); // 'old' has an expired file mtime and is pruned; 'recent' has a fresh // file mtime and is kept, even though both were indexed. assert.strictEqual(pruned.sessionsRemoved, 1); const stats = getSessionStats(dbManager); assert.strictEqual(stats.totalSessions, 1); const remaining = dbManager.getDb().prepare('SELECT id FROM sessions').all() as { id: string }[]; assert.deepStrictEqual(remaining.map((r) => r.id), ['recent']); }); }); describe('pruneOldSessions', () => { it('removes nothing when the database is empty', () => { const result = pruneOldSessions(dbManager, 30); assert.deepStrictEqual(result, { sessionsRemoved: 0, messagesRemoved: 0 }); }); it('removes sessions older than the retention window and their messages', () => { indexSession(dbManager, createTestSession({ id: 'old', startedAt: '2020-01-01T00:00:00Z' })); indexSession(dbManager, createTestSession({ id: 'recent', startedAt: new Date().toISOString() })); const result = pruneOldSessions(dbManager, 30); assert.strictEqual(result.sessionsRemoved, 1); assert.strictEqual(result.messagesRemoved, 2); // old session has 2 messages const stats = getSessionStats(dbManager); assert.strictEqual(stats.totalSessions, 1); assert.strictEqual(stats.totalMessages, 2); const remaining = dbManager.getDb().prepare('SELECT id FROM sessions').all() as { id: string }[]; assert.deepStrictEqual(remaining.map((r) => r.id), ['recent']); }); it('leaves sessions exactly at the retention boundary (not strictly older) untouched', () => { const now = Date.now(); const justWithin = new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(); // 10 days ago indexSession(dbManager, createTestSession({ id: 'within', startedAt: justWithin })); const result = pruneOldSessions(dbManager, 30); assert.strictEqual(result.sessionsRemoved, 0); assert.strictEqual(dbManager.getStats().sessions, 1); }); it('is idempotent across repeated calls', () => { indexSession(dbManager, createTestSession({ id: 'old', startedAt: '2020-01-01T00:00:00Z' })); pruneOldSessions(dbManager, 30); const again = pruneOldSessions(dbManager, 30); assert.deepStrictEqual(again, { sessionsRemoved: 0, messagesRemoved: 0 }); }); }); describe('getSessionStats', () => { it('should return zero counts for empty database', () => { const stats = getSessionStats(dbManager); assert.strictEqual(stats.totalSessions, 0); assert.strictEqual(stats.totalMessages, 0); assert.deepStrictEqual(stats.projects, []); }); it('should return correct stats after indexing', () => { const session = createTestSession(); indexSession(dbManager, session); const stats = getSessionStats(dbManager); assert.strictEqual(stats.totalSessions, 1); assert.strictEqual(stats.totalMessages, 2); assert.strictEqual(stats.projects.length, 1); assert.strictEqual(stats.projects[0].project, 'test-project'); assert.strictEqual(stats.projects[0].sessions, 1); assert.strictEqual(stats.projects[0].messages, 2); }); it('should group by project', () => { indexSession(dbManager, createTestSession({ id: 's1', project: 'project-a' })); indexSession(dbManager, createTestSession({ id: 's2', project: 'project-a' })); indexSession(dbManager, createTestSession({ id: 's3', project: 'project-b' })); const stats = getSessionStats(dbManager); assert.strictEqual(stats.totalSessions, 3); assert.strictEqual(stats.projects.length, 2); const projA = stats.projects.find(p => p.project === 'project-a'); const projB = stats.projects.find(p => p.project === 'project-b'); assert.ok(projA); assert.ok(projB); assert.strictEqual(projA.sessions, 2); assert.strictEqual(projB.sessions, 1); }); }); });