mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-10 17:14:48 +00:00
370 lines
15 KiB
TypeScript
370 lines
15 KiB
TypeScript
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<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'])
|
|
})
|
|
})
|
|
|
|
// ── 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'])
|
|
})
|
|
})
|