codeburn/tests/parser-rich-capture.test.ts

213 lines
8.1 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
countStructuredPatchLoc,
collectToolResultMeta,
collectSessionMeta,
emptySessionMeta,
parseApiCall,
groupIntoTurns,
parsedTurnsToCachedTurns,
type ToolResultMeta,
} from '../src/parser.js'
import type { JournalEntry } from '../src/types.js'
// ── structuredPatch LOC counting ───────────────────────────────────────
describe('countStructuredPatchLoc', () => {
it('counts +/- lines across a single hunk', () => {
const patch = [
{ oldStart: 80, oldLines: 7, newStart: 80, newLines: 7, lines: [
' unchanged context',
'- old line',
'+ new line',
' more context',
] },
]
expect(countStructuredPatchLoc(patch)).toEqual({ added: 1, removed: 1 })
})
it('sums across multiple hunks', () => {
const patch = [
{ lines: ['+a', '+b', '-c', ' ctx'] },
{ lines: ['+d', '-e', '-f'] },
]
expect(countStructuredPatchLoc(patch)).toEqual({ added: 3, removed: 3 })
})
it('returns zero for an empty patch (Write-create shape)', () => {
expect(countStructuredPatchLoc([])).toEqual({ added: 0, removed: 0 })
})
it('returns zero for a missing/non-array patch', () => {
expect(countStructuredPatchLoc(undefined)).toEqual({ added: 0, removed: 0 })
expect(countStructuredPatchLoc(null)).toEqual({ added: 0, removed: 0 })
expect(countStructuredPatchLoc({ lines: ['+x'] })).toEqual({ added: 0, removed: 0 })
})
it('ignores hunks whose lines are absent or non-string', () => {
const patch = [{ oldStart: 1 }, { lines: [1, '+ok', null, '-no'] }]
expect(countStructuredPatchLoc(patch)).toEqual({ added: 1, removed: 1 })
})
})
// ── tool-result meta extraction + per-call attribution ─────────────────
function assistantEntry(id: string, toolUseIds: string[]): JournalEntry {
return {
type: 'assistant',
timestamp: '2026-07-01T10:00:00Z',
sessionId: 's1',
gitBranch: 'main',
message: {
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-20250514',
id,
content: toolUseIds.map(tid => ({ type: 'tool_use' as const, id: tid, name: 'Edit', input: {} })),
usage: { input_tokens: 10, output_tokens: 5 },
},
}
}
function toolResultEntry(opts: {
toolUseId: string
isError?: boolean
structuredPatch?: unknown
interrupted?: boolean
userModified?: boolean
}): JournalEntry {
return {
type: 'user',
timestamp: '2026-07-01T10:00:01Z',
sessionId: 's1',
gitBranch: 'main',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: opts.toolUseId, is_error: opts.isError, content: 'x' } as never],
},
toolUseResult: {
structuredPatch: opts.structuredPatch,
interrupted: opts.interrupted ?? false,
userModified: opts.userModified ?? false,
},
} as JournalEntry
}
describe('collectToolResultMeta + parseApiCall attribution', () => {
it('attributes LOC, interrupted, userModified, and toolErrors to the issuing call', () => {
const map = new Map<string, ToolResultMeta>()
collectToolResultMeta(toolResultEntry({
toolUseId: 'tu1',
structuredPatch: [{ lines: ['+a', '+b', '-c'] }],
userModified: true,
}), map)
collectToolResultMeta(toolResultEntry({
toolUseId: 'tu2',
isError: true,
interrupted: true,
}), map)
const call = parseApiCall(assistantEntry('m1', ['tu1', 'tu2']), map)
expect(call).not.toBeNull()
expect(call!.locAdded).toBe(2)
expect(call!.locRemoved).toBe(1)
expect(call!.toolErrors).toBe(1)
expect(call!.interrupted).toBe(true)
expect(call!.userModified).toBe(true)
})
it('omits all rich fields when no meta map is supplied', () => {
const call = parseApiCall(assistantEntry('m1', ['tu1']))
expect(call!.locAdded).toBeUndefined()
expect(call!.locRemoved).toBeUndefined()
expect(call!.toolErrors).toBeUndefined()
expect(call!.interrupted).toBeUndefined()
expect(call!.userModified).toBeUndefined()
})
it('omits fields when the map has no matching tool_use id', () => {
const map = new Map<string, ToolResultMeta>()
collectToolResultMeta(toolResultEntry({ toolUseId: 'other', structuredPatch: [{ lines: ['+a'] }] }), map)
const call = parseApiCall(assistantEntry('m1', ['tu1']), map)
expect(call!.locAdded).toBeUndefined()
expect(call!.toolErrors).toBeUndefined()
})
it('counts is_error but not a non-error result with stderr', () => {
// Bash results carry stderr for warnings; only is_error marks a real failure.
const map = new Map<string, ToolResultMeta>()
collectToolResultMeta(toolResultEntry({ toolUseId: 'tu1', isError: false }), map)
const call = parseApiCall(assistantEntry('m1', ['tu1']), map)
expect(call!.toolErrors).toBeUndefined()
})
})
// ── gitBranch representation (per-turn dedup) ──────────────────────────
function userText(text: string, branch: string, sessionId = 's1'): JournalEntry {
return { type: 'user', timestamp: '2026-07-01T10:00:00Z', sessionId, gitBranch: branch, message: { role: 'user', content: text } }
}
function assistant(id: string, branch: string, sessionId = 's1'): JournalEntry {
return {
type: 'assistant', timestamp: '2026-07-01T10:00:02Z', sessionId, gitBranch: branch,
message: { type: 'message', role: 'assistant', model: 'claude-sonnet-4-20250514', id, content: [], usage: { input_tokens: 5, output_tokens: 5 } },
}
}
describe('gitBranch capture + dedup', () => {
it('stores the branch once for a single-branch session', () => {
const entries = [
userText('first', 'main'), assistant('m1', 'main'),
userText('second', 'main'), assistant('m2', 'main'),
]
const turns = groupIntoTurns(entries, new Set())
expect(turns.map(t => t.gitBranch)).toEqual(['main', 'main'])
const cached = parsedTurnsToCachedTurns(turns)
// First turn stores 'main'; second inherits (no stored branch).
expect(cached[0]!.gitBranch).toBe('main')
expect(cached[1]!.gitBranch).toBeUndefined()
})
it('re-stores the branch at a mid-session switch', () => {
const entries = [
userText('a', 'main'), assistant('m1', 'main'),
userText('b', 'feature/x'), assistant('m2', 'feature/x'),
userText('c', 'feature/x'), assistant('m3', 'feature/x'),
userText('d', 'main'), assistant('m4', 'main'),
]
const turns = groupIntoTurns(entries, new Set())
expect(turns.map(t => t.gitBranch)).toEqual(['main', 'feature/x', 'feature/x', 'main'])
const cached = parsedTurnsToCachedTurns(turns)
expect(cached.map(t => t.gitBranch)).toEqual(['main', 'feature/x', undefined, 'main'])
})
})
// ── session meta: ai-title last-wins, pr-link accumulation ─────────────
describe('collectSessionMeta', () => {
it('keeps the LAST ai-title and accumulates unique pr-link URLs', () => {
const meta = emptySessionMeta()
collectSessionMeta({ type: 'ai-title', aiTitle: 'first title' } as JournalEntry, meta)
collectSessionMeta({ type: 'pr-link', prUrl: 'https://github.com/o/r/pull/1' } as JournalEntry, meta)
collectSessionMeta({ type: 'ai-title', aiTitle: 'final title' } as JournalEntry, meta)
collectSessionMeta({ type: 'pr-link', prUrl: 'https://github.com/o/r/pull/1' } as JournalEntry, meta)
collectSessionMeta({ type: 'pr-link', prUrl: 'https://github.com/o/r/pull/2' } as JournalEntry, meta)
collectSessionMeta({ type: 'user', isSidechain: true } as JournalEntry, meta)
expect(meta.title).toBe('final title')
expect(meta.prLinks).toEqual([
'https://github.com/o/r/pull/1',
'https://github.com/o/r/pull/2',
])
expect(meta.isSidechain).toBe(true)
})
it('leaves fields empty for a session with no meta entries', () => {
const meta = emptySessionMeta()
collectSessionMeta({ type: 'user', message: { role: 'user', content: 'hi' } } as JournalEntry, meta)
expect(meta.title).toBeUndefined()
expect(meta.prLinks).toEqual([])
expect(meta.isSidechain).toBe(false)
})
})