import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { describe, expect, it } from 'vitest'; import { analyzeSessionFileMetadata, calculateMetrics, parseJsonlFile, parseJsonlLine, } from '../../../src/main/utils/jsonl'; import type { ParsedMessage } from '../../../src/main/types'; // Helper to create a minimal ParsedMessage function createMessage(overrides: Partial = {}): ParsedMessage { return { uuid: 'test-uuid', parentUuid: null, type: 'assistant', timestamp: new Date('2024-01-01T10:00:00Z'), content: '', isSidechain: false, isMeta: false, isCompactSummary: false, toolCalls: [], toolResults: [], ...overrides, }; } describe('jsonl', () => { describe('calculateMetrics', () => { it('should return empty metrics for empty messages array', () => { const result = calculateMetrics([]); expect(result.durationMs).toBe(0); expect(result.totalTokens).toBe(0); expect(result.inputTokens).toBe(0); expect(result.outputTokens).toBe(0); expect(result.messageCount).toBe(0); }); it('should calculate total tokens from usage', () => { const messages = [ createMessage({ usage: { input_tokens: 100, output_tokens: 50, }, }), ]; const result = calculateMetrics(messages); expect(result.inputTokens).toBe(100); expect(result.outputTokens).toBe(50); expect(result.totalTokens).toBe(150); }); it('should sum tokens across multiple messages', () => { const messages = [ createMessage({ usage: { input_tokens: 100, output_tokens: 50 }, }), createMessage({ usage: { input_tokens: 200, output_tokens: 100 }, }), ]; const result = calculateMetrics(messages); expect(result.inputTokens).toBe(300); expect(result.outputTokens).toBe(150); expect(result.totalTokens).toBe(450); }); it('should handle cache tokens', () => { const messages = [ createMessage({ usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 25, cache_creation_input_tokens: 10, }, }), ]; const result = calculateMetrics(messages); expect(result.cacheReadTokens).toBe(25); expect(result.cacheCreationTokens).toBe(10); expect(result.totalTokens).toBe(185); // 100 + 50 + 25 + 10 }); it('should calculate duration from timestamps', () => { const messages = [ createMessage({ timestamp: new Date('2024-01-01T10:00:00Z') }), createMessage({ timestamp: new Date('2024-01-01T10:01:00Z') }), createMessage({ timestamp: new Date('2024-01-01T10:02:00Z') }), ]; const result = calculateMetrics(messages); expect(result.durationMs).toBe(120000); // 2 minutes in ms }); it('should count messages', () => { const messages = [createMessage(), createMessage(), createMessage()]; const result = calculateMetrics(messages); expect(result.messageCount).toBe(3); }); it('should handle messages without usage', () => { const messages = [ createMessage({ type: 'user', content: 'Hello' }), createMessage({ type: 'system' }), ]; const result = calculateMetrics(messages); expect(result.totalTokens).toBe(0); expect(result.messageCount).toBe(2); }); it('should handle single message duration', () => { const messages = [createMessage({ timestamp: new Date('2024-01-01T10:00:00Z') })]; const result = calculateMetrics(messages); expect(result.durationMs).toBe(0); // min === max }); it('should handle undefined token values', () => { const messages = [ createMessage({ usage: { input_tokens: undefined as unknown as number, output_tokens: 50, }, }), ]; const result = calculateMetrics(messages); expect(result.inputTokens).toBe(0); expect(result.outputTokens).toBe(50); }); }); describe('analyzeSessionFileMetadata', () => { it('should extract first message, count, ongoing state, and git branch in one pass', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-meta-')); try { const filePath = path.join(tempDir, 'session.jsonl'); const lines = [ JSON.stringify({ type: 'user', uuid: 'u1', timestamp: '2026-01-01T00:00:00.000Z', gitBranch: 'feature/test', message: { role: 'user', content: 'hello world' }, isMeta: false, }), JSON.stringify({ type: 'assistant', uuid: 'a1', timestamp: '2026-01-01T00:00:01.000Z', message: { role: 'assistant', content: [{ type: 'thinking', thinking: 'thinking...' }], }, }), ]; fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); const result = await analyzeSessionFileMetadata(filePath); expect(result.firstUserMessage?.text).toBe('hello world'); expect(result.firstUserMessage?.timestamp).toBe('2026-01-01T00:00:00.000Z'); expect(result.messageCount).toBe(2); expect(result.isOngoing).toBe(true); expect(result.gitBranch).toBe('feature/test'); } finally { try { fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200, }); } catch { // Best-effort cleanup; ignore ENOTEMPTY on Windows when dir is in use } } }); }); describe('tolerant parsing', () => { it('skips non-JSON garbage and ignores a partial trailing object', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-tolerant-')); try { const filePath = path.join(tempDir, 'session.jsonl'); const validLine = JSON.stringify({ type: 'assistant', uuid: 'a1', timestamp: '2026-01-01T00:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }], }, }); const nonJsonGarbage = '╨╕┬аAI-╤А╨░╨╖╤А╨░╨▒'; const partialJson = '{"type":"assistant","uuid":"a2","timestamp":"2026-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"partial"'; fs.writeFileSync(filePath, `${validLine}\n${nonJsonGarbage}\n${partialJson}`, 'utf8'); const result = await parseJsonlFile(filePath); expect(result).toHaveLength(1); expect(result[0]?.uuid).toBe('a1'); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } }); it('strips a UTF-8 BOM before parsing an object line', () => { const parsed = parseJsonlLine( `\ufeff${JSON.stringify({ type: 'assistant', uuid: 'bom-1', timestamp: '2026-01-01T00:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'bom' }], }, })}` ); expect(parsed?.uuid).toBe('bom-1'); }); it('preserves real transcript metadata needed by task-log fallback selection', () => { const parsed = parseJsonlLine( JSON.stringify({ parentUuid: 'assistant-1', isSidechain: false, userType: 'external', cwd: '/tmp/project', sessionId: 'session-real-1', version: '1.0.0', gitBranch: 'main', type: 'user', uuid: 'user-real-1', timestamp: '2026-04-12T15:36:14.250Z', agentName: 'tom', isMeta: true, sourceToolAssistantUUID: 'assistant-1', sourceToolUseID: 'call-bash-real', toolUseResult: { toolUseId: 'call-bash-real', stdout: 'tests ok', exitCode: 0, }, message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'call-bash-real', content: 'tests ok', }, ], }, }), ); expect(parsed?.sessionId).toBe('session-real-1'); expect(parsed?.agentName).toBe('tom'); expect(parsed?.isMeta).toBe(true); expect(parsed?.sourceToolAssistantUUID).toBe('assistant-1'); expect(parsed?.sourceToolUseID).toBe('call-bash-real'); expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real'); }); }); });