import { describe, it, expect } from 'vitest' import { countStructuredPatchLoc, collectToolResultMeta, collectSessionMeta, emptySessionMeta, parseApiCall, groupIntoTurns, parsedTurnsToCachedTurns, buildSpawnPrSets, 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() 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() 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() 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']) }) }) // ── per-turn PR references (prRefs) ──────────────────────────────────── function prLink(url: string, sessionId = 's1'): JournalEntry { return { type: 'pr-link', timestamp: '2026-07-01T10:00:03Z', sessionId, prUrl: url } as JournalEntry } describe('pr-link capture + per-turn prRefs', () => { it('attaches a PR referenced during a turn to that turn, and it survives caching', () => { const entries = [ userText('open a PR', 'main'), assistant('m1', 'main'), prLink('https://github.com/o/r/pull/1'), userText('next task', 'main'), assistant('m2', 'main'), ] const turns = groupIntoTurns(entries, new Set()) expect(turns[0]!.prRefs).toEqual(['https://github.com/o/r/pull/1']) expect(turns[1]!.prRefs).toBeUndefined() const cached = parsedTurnsToCachedTurns(turns) // Stored per-turn directly (no change-detection dedup like gitBranch). expect(cached[0]!.prRefs).toEqual(['https://github.com/o/r/pull/1']) expect(cached[1]!.prRefs).toBeUndefined() }) it('sorts and dedupes multiple refs within one merge-sweep turn', () => { const entries = [ userText('merge sweep', 'main'), assistant('m1', 'main'), prLink('https://github.com/o/r/pull/2'), prLink('https://github.com/o/r/pull/1'), prLink('https://github.com/o/r/pull/2'), ] const turns = groupIntoTurns(entries, new Set()) expect(turns[0]!.prRefs).toEqual([ 'https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2', ]) }) }) // ── 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) expect(meta.parentSessionId).toBeUndefined() expect(meta.agentSpawnLinks).toEqual({}) }) }) // ── subagent linkage: parentSessionId + agentSpawnLinks ──────────────── describe('collectSessionMeta subagent linkage', () => { it("captures a sidechain's parent id from its internal sessionId (first wins)", () => { const meta = emptySessionMeta() collectSessionMeta({ type: 'user', isSidechain: true, sessionId: 'parent-1' } as JournalEntry, meta) // A later entry restating a different id must not override the first capture. collectSessionMeta({ type: 'assistant', isSidechain: true, sessionId: 'parent-2' } as JournalEntry, meta) expect(meta.isSidechain).toBe(true) expect(meta.parentSessionId).toBe('parent-1') }) it('maps agentId -> spawn tool_use id from a spawn result (toolUseResult.agentId)', () => { const meta = emptySessionMeta() const spawnResult = { type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_spawn1', content: 'agent output' }] }, toolUseResult: { status: 'completed', agentId: 'a17e80ec626c9de38' }, } as JournalEntry collectSessionMeta(spawnResult, meta) expect(meta.agentSpawnLinks).toEqual({ a17e80ec626c9de38: 'toolu_spawn1' }) // A non-agent tool_result (no toolUseResult.agentId) adds nothing. collectSessionMeta({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_edit', content: 'x' }] }, toolUseResult: { structuredPatch: [] }, } as JournalEntry, meta) expect(meta.agentSpawnLinks).toEqual({ a17e80ec626c9de38: 'toolu_spawn1' }) }) it('pairs the agentId with the matching block when a record batches several results (unrelated block first)', () => { const meta = emptySessionMeta() collectSessionMeta({ type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'toolu_unrelated', content: 'a bash result' }, { type: 'tool_result', tool_use_id: 'toolu_spawn', content: 'agent output' }, ] }, // The result's content identifies the spawn block, so the FIRST (unrelated) // block must not capture the agentId. toolUseResult: { status: 'completed', agentId: 'a999', content: 'agent output' }, } as JournalEntry, meta) expect(meta.agentSpawnLinks).toEqual({ a999: 'toolu_spawn' }) }) it('omits the spawn link (deferring to the timestamp fallback) when blocks are ambiguous', () => { const meta = emptySessionMeta() collectSessionMeta({ type: 'user', // Two tool_result blocks with IDENTICAL content: the content match cannot // pick one, so the link is omitted on purpose (resolveChild's timestamp // fallback then folds the child rather than risking the wrong id). message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'toolu_a', content: 'same' }, { type: 'tool_result', tool_use_id: 'toolu_b', content: 'same' }, ] }, toolUseResult: { status: 'completed', agentId: 'a777', content: 'same' }, } as JournalEntry, meta) expect(meta.agentSpawnLinks).toEqual({}) // no guess // But we KNOW a777 was spawned here, so it is recorded as an ambiguous pairing. expect(meta.ambiguousSpawnAgentIds).toEqual(['a777']) }) }) // ── per-turn subagent spawn ids (spawnToolUseIds) ────────────────────── function spawnAssistant(id: string, blocks: Array<{ id: string; name: string }>): JournalEntry { return { type: 'assistant', timestamp: '2026-07-01T10:00:02Z', sessionId: 's1', gitBranch: 'main', message: { type: 'message', role: 'assistant', model: 'claude-sonnet-4-20250514', id, content: blocks.map(b => ({ type: 'tool_use' as const, id: b.id, name: b.name, input: {} })), usage: { input_tokens: 5, output_tokens: 5 }, }, } } describe('per-turn spawnToolUseIds capture', () => { it('records only Agent/Task spawn ids per turn and survives caching', () => { const entries = [ userText('launch reviewers', 'main'), spawnAssistant('m1', [ { id: 'toolu_agent', name: 'Agent' }, { id: 'toolu_task', name: 'Task' }, { id: 'toolu_edit', name: 'Edit' }, // not a spawn -> excluded ]), userText('unrelated turn', 'main'), assistant('m2', 'main'), ] const turns = groupIntoTurns(entries, new Set()) expect(turns[0]!.spawnToolUseIds).toEqual(['toolu_agent', 'toolu_task']) expect(turns[1]!.spawnToolUseIds).toBeUndefined() const cached = parsedTurnsToCachedTurns(turns) expect(cached[0]!.spawnToolUseIds).toEqual(['toolu_agent', 'toolu_task']) expect(cached[1]!.spawnToolUseIds).toBeUndefined() }) }) describe('buildSpawnPrSets', () => { it('maps each spawn id to the PR set active at its turn, carrying refs forward', () => { const sets = buildSpawnPrSets([ { spawnToolUseIds: ['s0'] }, // before any PR -> empty { prRefs: ['pr/A'], spawnToolUseIds: ['s1'] }, // spawn under A { spawnToolUseIds: ['s2'] }, // carries A { prRefs: ['pr/B'], spawnToolUseIds: ['s3'] }, // spawn under B ]) expect(sets).toEqual({ s0: [], s1: ['pr/A'], s2: ['pr/A'], s3: ['pr/B'] }) }) it('first occurrence of a spawn id wins deterministically', () => { const sets = buildSpawnPrSets([ { prRefs: ['pr/A'], spawnToolUseIds: ['dup'] }, { prRefs: ['pr/B'], spawnToolUseIds: ['dup'] }, // restatement must not overwrite ]) expect(sets['dup']).toEqual(['pr/A']) }) })