diff --git a/api/services/sessions/parser.ts b/api/services/sessions/parser.ts index c53cab4..5003383 100644 --- a/api/services/sessions/parser.ts +++ b/api/services/sessions/parser.ts @@ -46,11 +46,11 @@ function classifyEventType(raw: Record): SessionEventType { function deriveOutcome(events: SessionEvent[]): SessionOutcome { if (events.length === 0) return 'unknown' const last = events[events.length - 1] + if (last.type !== 'assistant') return 'interrupted' const raw = last.raw as { message?: { stop_reason?: string } } - if (last.type === 'assistant' && raw.message?.stop_reason && raw.message.stop_reason !== 'end_turn' && raw.message.stop_reason !== 'tool_use') { - if (raw.message.stop_reason === 'error') return 'error' - } - if (last.type === 'assistant') return 'completed' + const stopReason = raw.message?.stop_reason + if (stopReason === 'error') return 'error' + if (stopReason === 'end_turn' || stopReason === 'tool_use' || !stopReason) return 'completed' return 'interrupted' } @@ -71,89 +71,98 @@ export async function parseSessionFile(jsonlPath: string): Promise let lastTs: string | null = null let idx = 0 - for await (const line of rl) { - if (!line.trim()) continue - let raw: Record - try { - raw = JSON.parse(line) - } catch { - continue - } - const type = classifyEventType(raw) - const timestamp = typeof raw.timestamp === 'string' ? raw.timestamp : null - if (timestamp) { - if (!firstTs) firstTs = timestamp - lastTs = timestamp - } - if (type === 'permission-mode' && typeof raw.permissionMode === 'string') { - permissionMode = raw.permissionMode - } - if (typeof raw.sessionId === 'string') sessionId = raw.sessionId - - const text = extractText((raw as { message?: unknown }).message) - let toolName: string | null = null - let toolInput: Record | null = null - let toolResult: unknown = null - - if (type === 'assistant' && raw.message && typeof raw.message === 'object') { - const content = (raw.message as { content?: unknown[] }).content - if (Array.isArray(content)) { - for (const part of content) { - if (part && typeof part === 'object' && 'type' in part) { - const p = part as { type: string; name?: string; input?: Record } - if (p.type === 'tool_use' && typeof p.name === 'string') { - toolCalls++ - toolsUsed.add(p.name) - toolName = p.name - toolInput = p.input ?? null - } - } - } + try { + for await (const line of rl) { + if (!line.trim()) continue + let raw: Record + try { + raw = JSON.parse(line) + } catch { + continue } - assistantMsgs++ - } - - if (type === 'user') { - userMessages++ - if (!firstPrompt && text && !text.startsWith('<') && text.length > 0) { - firstPrompt = text.slice(0, 500) + const type = classifyEventType(raw) + const timestamp = typeof raw.timestamp === 'string' ? raw.timestamp : null + if (timestamp) { + if (!firstTs) firstTs = timestamp + lastTs = timestamp } - if (raw.message && typeof raw.message === 'object') { + if (type === 'permission-mode' && typeof raw.permissionMode === 'string') { + permissionMode = raw.permissionMode + } + if (typeof raw.sessionId === 'string') sessionId = raw.sessionId + + const text = extractText((raw as { message?: unknown }).message) + let toolName: string | null = null + let toolInput: Record | null = null + let toolResult: unknown = null + + if (type === 'assistant' && raw.message && typeof raw.message === 'object') { const content = (raw.message as { content?: unknown[] }).content if (Array.isArray(content)) { for (const part of content) { if (part && typeof part === 'object' && 'type' in part) { - const p = part as { type: string; content?: unknown } - if (p.type === 'tool_result') toolResult = p.content + const p = part as { type: string; name?: string; input?: Record } + if (p.type === 'tool_use' && typeof p.name === 'string') { + toolCalls++ + toolsUsed.add(p.name) + toolName = p.name + toolInput = p.input ?? null + } + } + } + } + assistantMsgs++ + } + + if (type === 'user') { + userMessages++ + if (!firstPrompt && text && !text.startsWith('<') && text.length > 0) { + firstPrompt = text.slice(0, 500) + } + if (raw.message && typeof raw.message === 'object') { + const content = (raw.message as { content?: unknown[] }).content + if (Array.isArray(content)) { + for (const part of content) { + if (part && typeof part === 'object' && 'type' in part) { + const p = part as { type: string; content?: unknown } + if (p.type === 'tool_result') toolResult = p.content + } } } } } + + const skill = detectSkillInvoked(text) + if (skill) skillsInvoked.add(skill) + const hook = detectHook(text) + + events.push({ + index: idx++, + type, + timestamp, + raw, + text, + tool_name: toolName, + tool_input: toolInput, + tool_result: toolResult, + skill_invoked: skill, + hook_name: hook, + }) } - - const skill = detectSkillInvoked(text) - if (skill) skillsInvoked.add(skill) - const hook = detectHook(text) - - events.push({ - index: idx++, - type, - timestamp, - raw, - text, - tool_name: toolName, - tool_input: toolInput, - tool_result: toolResult, - skill_invoked: skill, - hook_name: hook, - }) + } finally { + rl.close() + stream.destroy() } if (!sessionId) { sessionId = basename(jsonlPath, '.jsonl') } - const durationSec = firstTs && lastTs ? Math.max(0, Math.round((Date.parse(lastTs) - Date.parse(firstTs)) / 1000)) : null + let durationSec: number | null = null + if (firstTs && lastTs) { + const diff = Date.parse(lastTs) - Date.parse(firstTs) + if (Number.isFinite(diff)) durationSec = Math.max(0, Math.round(diff / 1000)) + } const outcome = deriveOutcome(events) const stats = statSync(jsonlPath) diff --git a/api/tests/sessions-parser.test.ts b/api/tests/sessions-parser.test.ts index 5cd3f05..a456a4b 100644 --- a/api/tests/sessions-parser.test.ts +++ b/api/tests/sessions-parser.test.ts @@ -76,4 +76,25 @@ describe('parseSessionFile', () => { const result = await parseSessionFile(path) expect(result.meta.user_messages).toBe(1) }) + + it('devolve duration_sec null quando timestamps são inválidos', async () => { + const path = writeJsonl([ + { type: 'user', timestamp: 'not-a-date', message: { role: 'user', content: [{ type: 'text', text: 'a' }] } }, + { type: 'assistant', timestamp: 'also-not-a-date', message: { role: 'assistant', content: [{ type: 'text', text: 'b' }] } }, + ]) + const result = await parseSessionFile(path) + expect(result.meta.duration_sec).toBeNull() + }) + + it('classifica max_tokens como interrupted', async () => { + const path = writeJsonl([ + { + type: 'assistant', + timestamp: '2026-04-23T10:00:00Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'resposta cortada' }], stop_reason: 'max_tokens' }, + }, + ]) + const result = await parseSessionFile(path) + expect(result.meta.outcome).toBe('interrupted') + }) })