diff --git a/src/__tests__/session-reader.test.ts b/src/__tests__/session-reader.test.ts index b5baed0..33f91d3 100644 --- a/src/__tests__/session-reader.test.ts +++ b/src/__tests__/session-reader.test.ts @@ -26,6 +26,30 @@ describe('loadSessionIndex', () => { expect(result).toEqual([]); }); + it('falls back to jsonl files when sessions-index.json does not exist', () => { + const filePath = join(mockSessionsDir, 'fallback-session.jsonl'); + writeFileSync(filePath, [ + JSON.stringify({ + type: 'user', + gitBranch: 'develop', + message: { content: [{ type: 'text', text: 'Resume me from jsonl fallback' }] }, + isSidechain: false, + }), + JSON.stringify({ + type: 'assistant', + message: { content: [{ type: 'text', text: 'Sure' }] }, + }), + ].join('\n')); + + const result = loadSessionIndex('/any'); + + expect(result).toHaveLength(1); + expect(result[0]!.sessionId).toBe('fallback-session'); + expect(result[0]!.firstPrompt).toBe('Resume me from jsonl fallback'); + expect(result[0]!.messageCount).toBe(2); + expect(result[0]!.gitBranch).toBe('develop'); + }); + it('reads and parses sessions-index.json correctly', () => { const indexData = { version: 1, @@ -105,6 +129,36 @@ describe('loadSessionIndex', () => { const result = loadSessionIndex('/any'); expect(result).toEqual([]); }); + + it('uses jsonl fallback when sessions-index.json is corrupted', () => { + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), '{corrupted json content'); + writeFileSync(join(mockSessionsDir, 'fallback-on-corrupt.jsonl'), JSON.stringify({ + type: 'user', + message: { content: [{ type: 'text', text: 'Fallback on corrupted index' }] }, + isSidechain: false, + })); + + const result = loadSessionIndex('/any'); + expect(result).toHaveLength(1); + expect(result[0]!.sessionId).toBe('fallback-on-corrupt'); + }); + + it('filters out sidechain sessions in jsonl fallback', () => { + writeFileSync(join(mockSessionsDir, 'main-session.jsonl'), JSON.stringify({ + type: 'user', + message: { content: [{ type: 'text', text: 'Main session' }] }, + isSidechain: false, + })); + writeFileSync(join(mockSessionsDir, 'sidechain-session.jsonl'), JSON.stringify({ + type: 'user', + message: { content: [{ type: 'text', text: 'Sidechain session' }] }, + isSidechain: true, + })); + + const result = loadSessionIndex('/any'); + expect(result).toHaveLength(1); + expect(result[0]!.sessionId).toBe('main-session'); + }); }); describe('extractLastAssistantResponse', () => { diff --git a/src/infra/claude/session-reader.ts b/src/infra/claude/session-reader.ts index 82e6b1e..dc2bcdb 100644 --- a/src/infra/claude/session-reader.ts +++ b/src/infra/claude/session-reader.ts @@ -5,7 +5,7 @@ * to extract session metadata and last assistant responses. */ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { getClaudeProjectSessionsDir } from '../config/project/sessionStore.js'; @@ -26,6 +26,99 @@ interface SessionsIndex { entries: SessionIndexEntry[]; } +interface SessionMessageContent { + type: string; + text?: string; +} + +interface SessionMessage { + content?: SessionMessageContent[]; +} + +interface SessionJsonlRecord { + type?: string; + sessionId?: string; + message?: SessionMessage; + timestamp?: string; + gitBranch?: string; + isSidechain?: boolean; +} + +function buildEntryFromJsonlFile(sessionsDir: string, fileName: string): SessionIndexEntry | null { + const fullPath = join(sessionsDir, fileName); + const sessionId = fileName.replace(/\.jsonl$/, ''); + + if (!sessionId || sessionId === fileName) { + return null; + } + + let firstPrompt = ''; + let messageCount = 0; + let gitBranch = ''; + let isSidechain = false; + + try { + const content = readFileSync(fullPath, 'utf-8'); + const lines = content.split('\n').filter((line) => line.trim().length > 0); + for (const line of lines) { + let record: SessionJsonlRecord; + try { + record = JSON.parse(line) as SessionJsonlRecord; + } catch { + continue; + } + + if (record.type === 'user' || record.type === 'assistant') { + messageCount += 1; + } + + if (!gitBranch && typeof record.gitBranch === 'string') { + gitBranch = record.gitBranch; + } + + if (record.isSidechain === true) { + isSidechain = true; + } + + if (!firstPrompt && record.type === 'user' && Array.isArray(record.message?.content)) { + const textBlock = record.message.content.find((block) => block.type === 'text' && typeof block.text === 'string'); + if (textBlock?.text) { + firstPrompt = textBlock.text.trim(); + } + } + } + } catch { + return null; + } + + const modified = statSync(fullPath).mtime.toISOString(); + + return { + sessionId, + firstPrompt: firstPrompt || sessionId, + modified, + messageCount, + gitBranch, + isSidechain, + fullPath, + }; +} + +function loadSessionIndexFromJsonl(sessionsDir: string): SessionIndexEntry[] { + if (!existsSync(sessionsDir)) { + return []; + } + + const jsonlFiles = readdirSync(sessionsDir) + .filter((name) => name.endsWith('.jsonl')); + + return jsonlFiles + .map((fileName) => buildEntryFromJsonlFile(sessionsDir, fileName)) + .filter((entry): entry is SessionIndexEntry => entry !== null) + .filter((entry) => !entry.isSidechain) + .sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); +} + /** * Load the session index for a project directory. * @@ -37,7 +130,7 @@ export function loadSessionIndex(projectDir: string): SessionIndexEntry[] { const indexPath = join(sessionsDir, 'sessions-index.json'); if (!existsSync(indexPath)) { - return []; + return loadSessionIndexFromJsonl(sessionsDir); } const content = readFileSync(indexPath, 'utf-8'); @@ -46,11 +139,11 @@ export function loadSessionIndex(projectDir: string): SessionIndexEntry[] { try { index = JSON.parse(content) as SessionsIndex; } catch { - return []; + return loadSessionIndexFromJsonl(sessionsDir); } if (!index.entries || !Array.isArray(index.entries)) { - return []; + return loadSessionIndexFromJsonl(sessionsDir); } return index.entries