fix(observabilidade): stream cleanup, outcome logic e NaN guard no parser
This commit is contained in:
@@ -46,11 +46,11 @@ function classifyEventType(raw: Record<string, unknown>): SessionEventType {
|
|||||||
function deriveOutcome(events: SessionEvent[]): SessionOutcome {
|
function deriveOutcome(events: SessionEvent[]): SessionOutcome {
|
||||||
if (events.length === 0) return 'unknown'
|
if (events.length === 0) return 'unknown'
|
||||||
const last = events[events.length - 1]
|
const last = events[events.length - 1]
|
||||||
|
if (last.type !== 'assistant') return 'interrupted'
|
||||||
const raw = last.raw as { message?: { stop_reason?: string } }
|
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') {
|
const stopReason = raw.message?.stop_reason
|
||||||
if (raw.message.stop_reason === 'error') return 'error'
|
if (stopReason === 'error') return 'error'
|
||||||
}
|
if (stopReason === 'end_turn' || stopReason === 'tool_use' || !stopReason) return 'completed'
|
||||||
if (last.type === 'assistant') return 'completed'
|
|
||||||
return 'interrupted'
|
return 'interrupted'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,89 +71,98 @@ export async function parseSessionFile(jsonlPath: string): Promise<ParseResult>
|
|||||||
let lastTs: string | null = null
|
let lastTs: string | null = null
|
||||||
let idx = 0
|
let idx = 0
|
||||||
|
|
||||||
for await (const line of rl) {
|
try {
|
||||||
if (!line.trim()) continue
|
for await (const line of rl) {
|
||||||
let raw: Record<string, unknown>
|
if (!line.trim()) continue
|
||||||
try {
|
let raw: Record<string, unknown>
|
||||||
raw = JSON.parse(line)
|
try {
|
||||||
} catch {
|
raw = JSON.parse(line)
|
||||||
continue
|
} 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<string, unknown> | 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<string, unknown> }
|
|
||||||
if (p.type === 'tool_use' && typeof p.name === 'string') {
|
|
||||||
toolCalls++
|
|
||||||
toolsUsed.add(p.name)
|
|
||||||
toolName = p.name
|
|
||||||
toolInput = p.input ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
assistantMsgs++
|
const type = classifyEventType(raw)
|
||||||
}
|
const timestamp = typeof raw.timestamp === 'string' ? raw.timestamp : null
|
||||||
|
if (timestamp) {
|
||||||
if (type === 'user') {
|
if (!firstTs) firstTs = timestamp
|
||||||
userMessages++
|
lastTs = timestamp
|
||||||
if (!firstPrompt && text && !text.startsWith('<') && text.length > 0) {
|
|
||||||
firstPrompt = text.slice(0, 500)
|
|
||||||
}
|
}
|
||||||
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<string, unknown> | null = null
|
||||||
|
let toolResult: unknown = null
|
||||||
|
|
||||||
|
if (type === 'assistant' && raw.message && typeof raw.message === 'object') {
|
||||||
const content = (raw.message as { content?: unknown[] }).content
|
const content = (raw.message as { content?: unknown[] }).content
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
for (const part of content) {
|
for (const part of content) {
|
||||||
if (part && typeof part === 'object' && 'type' in part) {
|
if (part && typeof part === 'object' && 'type' in part) {
|
||||||
const p = part as { type: string; content?: unknown }
|
const p = part as { type: string; name?: string; input?: Record<string, unknown> }
|
||||||
if (p.type === 'tool_result') toolResult = p.content
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
const skill = detectSkillInvoked(text)
|
rl.close()
|
||||||
if (skill) skillsInvoked.add(skill)
|
stream.destroy()
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
sessionId = basename(jsonlPath, '.jsonl')
|
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 outcome = deriveOutcome(events)
|
||||||
const stats = statSync(jsonlPath)
|
const stats = statSync(jsonlPath)
|
||||||
|
|
||||||
|
|||||||
@@ -76,4 +76,25 @@ describe('parseSessionFile', () => {
|
|||||||
const result = await parseSessionFile(path)
|
const result = await parseSessionFile(path)
|
||||||
expect(result.meta.user_messages).toBe(1)
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user