mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 15:05:16 +00:00
feat(capture): parse-time capture of branch, LOC deltas, interruptions, titles, PR links
This commit is contained in:
parent
2312fd2eb0
commit
8ced2ed47f
9 changed files with 743 additions and 15 deletions
|
|
@ -11,7 +11,9 @@ import type { ParsedProviderCall } from './providers/types.js'
|
|||
// v5: also attribute CLI-wrapped MCP calls (`mcp-cli call server tool`) that
|
||||
// Codex logs as a plain exec_command (issue #478 follow-up). Force a re-parse
|
||||
// so sessions cached under v4 pick up the CLI-MCP attribution.
|
||||
const CODEX_CACHE_VERSION = 5
|
||||
// v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from
|
||||
// patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add.
|
||||
const CODEX_CACHE_VERSION = 6
|
||||
const CACHE_FILE = 'codex-results.json'
|
||||
|
||||
type FileFingerprint = { mtimeMs: number; sizeBytes: number }
|
||||
|
|
|
|||
196
src/parser.ts
196
src/parser.ts
|
|
@ -524,7 +524,7 @@ function extractObjectFields(
|
|||
return captured
|
||||
}
|
||||
|
||||
const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'attachment', 'message'] as const
|
||||
const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message'] as const
|
||||
const LARGE_ASSISTANT_MESSAGE_FIELDS = ['model', 'usage', 'id', 'content'] as const
|
||||
|
||||
function parseLargeJsonl(line: string | Buffer): JournalEntry | null {
|
||||
|
|
@ -541,9 +541,11 @@ function parseLargeJsonl(line: string | Buffer): JournalEntry | null {
|
|||
const timestamp = readJsonString(source, root['timestamp'])
|
||||
const sessionId = readJsonString(source, root['sessionId'])
|
||||
const cwd = readJsonString(source, root['cwd'])
|
||||
const gitBranch = readJsonString(source, root['gitBranch'])
|
||||
if (timestamp !== undefined) entry.timestamp = timestamp
|
||||
if (sessionId !== undefined) entry.sessionId = sessionId
|
||||
if (cwd !== undefined) entry.cwd = cwd
|
||||
if (gitBranch !== undefined) entry.gitBranch = gitBranch
|
||||
const addedNames = extractLargeAddedNames(source, root['attachment'])
|
||||
if (addedNames.length > 0) {
|
||||
;(entry as Record<string, unknown>)['attachment'] = { type: 'deferred_tools_delta', addedNames }
|
||||
|
|
@ -907,6 +909,8 @@ export function compactEntry(raw: JournalEntry): JournalEntry {
|
|||
if (raw.timestamp !== undefined) entry.timestamp = raw.timestamp
|
||||
if (raw.sessionId !== undefined) entry.sessionId = raw.sessionId
|
||||
if (raw.cwd !== undefined) entry.cwd = raw.cwd
|
||||
// Preserved so groupIntoTurns can stamp each turn's git branch (rich capture).
|
||||
if (typeof raw.gitBranch === 'string' && raw.gitBranch) entry.gitBranch = raw.gitBranch
|
||||
|
||||
const att = (raw as Record<string, unknown>)['attachment']
|
||||
if (att && typeof att === 'object') {
|
||||
|
|
@ -1151,7 +1155,97 @@ function applyLocalModelSavings(call: ParsedApiCall): ParsedApiCall {
|
|||
}
|
||||
}
|
||||
|
||||
export function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
|
||||
// ── Rich Session Capture (Claude) ──────────────────────────────────────
|
||||
//
|
||||
// Parse-time extraction of edit sizes, interruptions, error counts, git branch,
|
||||
// and session titles/PR links from the raw JSONL. Capture-only: no report or
|
||||
// payload consumes these yet. Everything is optional and omitted at zero/false
|
||||
// to keep the cache cost minimal.
|
||||
|
||||
// Per-call metadata keyed by tool_use_id, built from a session's user
|
||||
// (tool-result) entries before compaction discards `toolUseResult` and the
|
||||
// tool_result blocks' `is_error` flag.
|
||||
export type ToolResultMeta = {
|
||||
locAdded: number
|
||||
locRemoved: number
|
||||
interrupted: boolean
|
||||
userModified: boolean
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
// Session-level accumulator: last `ai-title` wins, `pr-link` URLs accumulate,
|
||||
// and any sidechain entry flips `isSidechain`. parentUuid is deliberately not
|
||||
// captured as a session link — it references an intra-file entry uuid, not
|
||||
// another session's id, so it cannot reliably connect two sessions.
|
||||
export type SessionMeta = {
|
||||
title?: string
|
||||
prLinks: string[]
|
||||
isSidechain: boolean
|
||||
}
|
||||
|
||||
export function emptySessionMeta(): SessionMeta {
|
||||
return { prLinks: [], isSidechain: false }
|
||||
}
|
||||
|
||||
// Count added/removed lines from a Claude `toolUseResult.structuredPatch`. Each
|
||||
// hunk's `lines` array holds unified-diff content lines: a leading '+' is an
|
||||
// added line, '-' a removed line, ' ' context. Numbers only — patch text is
|
||||
// never stored. Missing/empty/non-array patches count as zero.
|
||||
export function countStructuredPatchLoc(patch: unknown): { added: number; removed: number } {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
if (!Array.isArray(patch)) return { added, removed }
|
||||
for (const hunk of patch) {
|
||||
const lines = (hunk as { lines?: unknown } | null)?.lines
|
||||
if (!Array.isArray(lines)) continue
|
||||
for (const line of lines) {
|
||||
if (typeof line !== 'string') continue
|
||||
if (line.startsWith('+')) added++
|
||||
else if (line.startsWith('-')) removed++
|
||||
}
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
// Record tool-result metadata from a raw user entry into `map`, keyed by the
|
||||
// tool_result block's tool_use_id. Must run on the RAW entry (before
|
||||
// compactEntry drops toolUseResult / is_error). Large tool-result lines parsed
|
||||
// as buffers lose toolUseResult (the byte scanner does not extract it) — an
|
||||
// accepted gap for oversized outputs.
|
||||
export function collectToolResultMeta(entry: JournalEntry, map: Map<string, ToolResultMeta>): void {
|
||||
if (entry.type !== 'user') return
|
||||
const msg = entry.message
|
||||
const content = msg && typeof msg === 'object' ? (msg as { content?: unknown }).content : undefined
|
||||
if (!Array.isArray(content)) return
|
||||
const tur = (entry as Record<string, unknown>)['toolUseResult']
|
||||
const turObj = tur && typeof tur === 'object' ? tur as Record<string, unknown> : undefined
|
||||
const loc = countStructuredPatchLoc(turObj?.['structuredPatch'])
|
||||
const interrupted = turObj?.['interrupted'] === true
|
||||
const userModified = turObj?.['userModified'] === true
|
||||
for (const b of content) {
|
||||
if (!b || typeof b !== 'object' || (b as { type?: unknown }).type !== 'tool_result') continue
|
||||
const id = (b as { tool_use_id?: unknown }).tool_use_id
|
||||
if (typeof id !== 'string' || !id) continue
|
||||
const isError = (b as { is_error?: unknown }).is_error === true
|
||||
map.set(id, { locAdded: loc.added, locRemoved: loc.removed, interrupted, userModified, isError })
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate session-level metadata from a raw entry. `ai-title` is last-wins
|
||||
// (Claude refines the title over the session); `pr-link` URLs union; any
|
||||
// sidechain entry marks the session.
|
||||
export function collectSessionMeta(entry: JournalEntry, meta: SessionMeta): void {
|
||||
if (entry.type === 'ai-title') {
|
||||
const t = (entry as Record<string, unknown>)['aiTitle']
|
||||
if (typeof t === 'string' && t.trim()) meta.title = t.trim().slice(0, 200)
|
||||
} else if (entry.type === 'pr-link') {
|
||||
const url = (entry as Record<string, unknown>)['prUrl']
|
||||
if (typeof url === 'string' && url && !meta.prLinks.includes(url)) meta.prLinks.push(url)
|
||||
}
|
||||
if (entry.isSidechain === true) meta.isSidechain = true
|
||||
}
|
||||
|
||||
export function parseApiCall(entry: JournalEntry, toolResultMeta?: Map<string, ToolResultMeta>): ParsedApiCall | null {
|
||||
if (entry.type !== 'assistant') return null
|
||||
const msg = entry.message as AssistantMessageContent | undefined
|
||||
if (!msg?.usage || !msg?.model) return null
|
||||
|
|
@ -1198,6 +1292,27 @@ export function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
|
|||
return [call]
|
||||
})
|
||||
|
||||
// Attribute tool-result metadata (edit LOC, interruptions, errors) to this
|
||||
// call by summing over the tool_use ids it issued. Omitted entirely when no
|
||||
// meta map is supplied (e.g. the guard usage path) or nothing was recorded.
|
||||
let locAdded = 0
|
||||
let locRemoved = 0
|
||||
let toolErrors = 0
|
||||
let interrupted = false
|
||||
let userModified = false
|
||||
if (toolResultMeta && toolResultMeta.size > 0) {
|
||||
for (const b of contentBlocks) {
|
||||
if (b.type !== 'tool_use') continue
|
||||
const m = toolResultMeta.get((b as ToolUseBlock).id)
|
||||
if (!m) continue
|
||||
locAdded += m.locAdded
|
||||
locRemoved += m.locRemoved
|
||||
if (m.isError) toolErrors++
|
||||
if (m.interrupted) interrupted = true
|
||||
if (m.userModified) userModified = true
|
||||
}
|
||||
}
|
||||
|
||||
return applyLocalModelSavings({
|
||||
provider: 'claude',
|
||||
model: msg.model,
|
||||
|
|
@ -1215,6 +1330,11 @@ export function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
|
|||
deduplicationKey: msg.id ?? `claude:${entry.timestamp}`,
|
||||
cacheCreationOneHourTokens: cacheCreation.oneHourTokens || undefined,
|
||||
toolSequence: toolSeq.length > 0 ? toolSeq : undefined,
|
||||
...(locAdded ? { locAdded } : {}),
|
||||
...(locRemoved ? { locRemoved } : {}),
|
||||
...(interrupted ? { interrupted: true } : {}),
|
||||
...(userModified ? { userModified: true } : {}),
|
||||
...(toolErrors ? { toolErrors } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1309,14 +1429,19 @@ export function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry
|
|||
return result
|
||||
}
|
||||
|
||||
function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>): ParsedTurn[] {
|
||||
export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>, toolResultMeta?: Map<string, ToolResultMeta>): ParsedTurn[] {
|
||||
const turns: ParsedTurn[] = []
|
||||
let currentUserMessage = ''
|
||||
let currentCalls: ParsedApiCall[] = []
|
||||
let currentTimestamp = ''
|
||||
let currentSessionId = ''
|
||||
// Git branch of the turn currently being accumulated. Captured at turn start
|
||||
// from the user entry (gitBranch is on every user/assistant entry); a
|
||||
// continuation turn with no leading user text falls back to its first call.
|
||||
let currentBranch: string | undefined
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryBranch = typeof entry.gitBranch === 'string' && entry.gitBranch ? entry.gitBranch : undefined
|
||||
if (entry.type === 'user') {
|
||||
const text = getUserMessageText(entry)
|
||||
if (text.trim()) {
|
||||
|
|
@ -1326,18 +1451,21 @@ function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>): Parse
|
|||
assistantCalls: currentCalls,
|
||||
timestamp: currentTimestamp,
|
||||
sessionId: currentSessionId,
|
||||
...(currentBranch ? { gitBranch: currentBranch } : {}),
|
||||
})
|
||||
}
|
||||
currentUserMessage = text
|
||||
currentCalls = []
|
||||
currentTimestamp = entry.timestamp ?? ''
|
||||
currentSessionId = entry.sessionId ?? ''
|
||||
currentBranch = entryBranch
|
||||
}
|
||||
} else if (entry.type === 'assistant') {
|
||||
if (entryBranch && !currentBranch) currentBranch = entryBranch
|
||||
const msgId = getMessageId(entry)
|
||||
if (msgId && seenMsgIds.has(msgId)) continue
|
||||
if (msgId) seenMsgIds.add(msgId)
|
||||
const call = parseApiCall(entry)
|
||||
const call = parseApiCall(entry, toolResultMeta)
|
||||
if (call) currentCalls.push(call)
|
||||
for (const advisorCall of parseAdvisorCalls(entry)) currentCalls.push(advisorCall)
|
||||
}
|
||||
|
|
@ -1349,6 +1477,7 @@ function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>): Parse
|
|||
assistantCalls: currentCalls,
|
||||
timestamp: currentTimestamp,
|
||||
sessionId: currentSessionId,
|
||||
...(currentBranch ? { gitBranch: currentBranch } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1731,11 +1860,13 @@ async function scanProjectDirs(
|
|||
// JSONL, this is the dominant warm-run cost. The merged result is
|
||||
// byte-for-byte identical to a full re-parse (see mergeBoundaryCalls).
|
||||
const tracker = { lastCompleteLineOffset: append.readFromOffset }
|
||||
const newEntries = await parseClaudeEntries(filePath, tracker, append.readFromOffset)
|
||||
const toolResultMeta = new Map<string, ToolResultMeta>()
|
||||
const sessionMeta = emptySessionMeta()
|
||||
const newEntries = await parseClaudeEntries(filePath, tracker, append.readFromOffset, { toolResultMeta, sessionMeta })
|
||||
const cached = append.cached
|
||||
|
||||
const newTurns = newEntries
|
||||
? groupIntoTurns(dedupeStreamingMessageIds(newEntries), seenMsgIds).map(parsedTurnToCachedTurn)
|
||||
? parsedTurnsToCachedTurns(groupIntoTurns(dedupeStreamingMessageIds(newEntries), seenMsgIds, toolResultMeta))
|
||||
: []
|
||||
|
||||
const mergedTurns: CachedTurn[] = cached.turns.map(t => ({ ...t, calls: [...t.calls] }))
|
||||
|
|
@ -1773,6 +1904,12 @@ async function scanProjectDirs(
|
|||
? Array.from(new Set([...cached.mcpInventory, ...extractMcpInventory(newEntries)])).sort()
|
||||
: cached.mcpInventory
|
||||
|
||||
// Session meta merges across the append boundary: title is last-wins
|
||||
// (prefer the newly-parsed tail), PR links union, isSidechain is sticky.
|
||||
const mergedTitle = sessionMeta.title ?? cached.title
|
||||
const mergedPrLinks = Array.from(new Set([...(cached.prLinks ?? []), ...sessionMeta.prLinks]))
|
||||
const mergedSidechain = cached.isSidechain === true || sessionMeta.isSidechain
|
||||
|
||||
section.files[filePath] = {
|
||||
fingerprint: info.fp,
|
||||
lastCompleteLineOffset: tracker.lastCompleteLineOffset,
|
||||
|
|
@ -1781,6 +1918,9 @@ async function scanProjectDirs(
|
|||
mcpInventory,
|
||||
turns: mergedTurns,
|
||||
agentType: cached.agentType,
|
||||
...(mergedTitle ? { title: mergedTitle } : {}),
|
||||
...(mergedPrLinks.length > 0 ? { prLinks: mergedPrLinks } : {}),
|
||||
...(mergedSidechain ? { isSidechain: true } : {}),
|
||||
}
|
||||
;(diskCache as { _dirty?: boolean })._dirty = true
|
||||
filesDone++
|
||||
|
|
@ -1793,10 +1933,12 @@ async function scanProjectDirs(
|
|||
}
|
||||
|
||||
const tracker = { lastCompleteLineOffset: 0 }
|
||||
const entries = await parseClaudeEntries(filePath, tracker)
|
||||
const toolResultMeta = new Map<string, ToolResultMeta>()
|
||||
const sessionMeta = emptySessionMeta()
|
||||
const entries = await parseClaudeEntries(filePath, tracker, undefined, { toolResultMeta, sessionMeta })
|
||||
if (!entries) { filesDone++; await parseProgress.tick(filesDone); continue }
|
||||
|
||||
const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), seenMsgIds)
|
||||
const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), seenMsgIds, toolResultMeta)
|
||||
const cwd = extractCanonicalCwd(entries)
|
||||
const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined
|
||||
section.files[filePath] = {
|
||||
|
|
@ -1805,8 +1947,11 @@ async function scanProjectDirs(
|
|||
canonicalCwd: canonical?.path,
|
||||
canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined,
|
||||
mcpInventory: extractMcpInventory(entries),
|
||||
turns: turns.map(parsedTurnToCachedTurn),
|
||||
turns: parsedTurnsToCachedTurns(turns),
|
||||
agentType: await readAgentType(filePath),
|
||||
...(sessionMeta.title ? { title: sessionMeta.title } : {}),
|
||||
...(sessionMeta.prLinks.length > 0 ? { prLinks: sessionMeta.prLinks } : {}),
|
||||
...(sessionMeta.isSidechain ? { isSidechain: true } : {}),
|
||||
}
|
||||
;(diskCache as { _dirty?: boolean })._dirty = true
|
||||
} catch (err) {
|
||||
|
|
@ -1997,6 +2142,9 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
|
|||
project: call.project,
|
||||
projectPath: call.projectPath,
|
||||
toolSequence: call.toolSequence,
|
||||
...(call.locAdded ? { locAdded: call.locAdded } : {}),
|
||||
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
|
||||
...(call.editFailed ? { editFailed: call.editFailed } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2027,6 +2175,11 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
|
|||
subagentTypes: call.subagentTypes,
|
||||
deduplicationKey: call.deduplicationKey,
|
||||
toolSequence: call.toolSequence,
|
||||
...(call.locAdded ? { locAdded: call.locAdded } : {}),
|
||||
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
|
||||
...(call.interrupted ? { interrupted: true } : {}),
|
||||
...(call.userModified ? { userModified: true } : {}),
|
||||
...(call.toolErrors ? { toolErrors: call.toolErrors } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2039,6 +2192,23 @@ function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn {
|
|||
}
|
||||
}
|
||||
|
||||
// Convert a batch of parsed turns to cached turns, storing each turn's gitBranch
|
||||
// only when it differs from the previous turn's branch in this batch. A report
|
||||
// reconstructs a turn's branch by carrying the last stored value forward. The
|
||||
// dedup is per-batch, so the first turn of an appended region always restates
|
||||
// its branch (harmless: a redundant restatement, never a wrong value).
|
||||
export function parsedTurnsToCachedTurns(turns: ParsedTurn[]): CachedTurn[] {
|
||||
const out: CachedTurn[] = []
|
||||
let prevBranch: string | undefined
|
||||
for (const turn of turns) {
|
||||
const cached = parsedTurnToCachedTurn(turn)
|
||||
if (turn.gitBranch && turn.gitBranch !== prevBranch) cached.gitBranch = turn.gitBranch
|
||||
if (turn.gitBranch) prevBranch = turn.gitBranch
|
||||
out.push(cached)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn {
|
||||
return {
|
||||
timestamp: call.timestamp,
|
||||
|
|
@ -2166,6 +2336,9 @@ async function parseClaudeEntries(
|
|||
filePath: string,
|
||||
tracker: { lastCompleteLineOffset: number },
|
||||
startByteOffset?: number,
|
||||
// Rich-capture collectors, populated from the RAW entry before compaction
|
||||
// strips toolUseResult / ai-title / pr-link / isSidechain.
|
||||
collectors?: { toolResultMeta?: Map<string, ToolResultMeta>; sessionMeta?: SessionMeta },
|
||||
): Promise<JournalEntry[] | null> {
|
||||
const entries: JournalEntry[] = []
|
||||
let hasLines = false
|
||||
|
|
@ -2176,7 +2349,10 @@ async function parseClaudeEntries(
|
|||
})) {
|
||||
hasLines = true
|
||||
const entry = parseJsonlLine(line)
|
||||
if (entry) entries.push(compactEntry(entry))
|
||||
if (!entry) continue
|
||||
if (collectors?.toolResultMeta) collectToolResultMeta(entry, collectors.toolResultMeta)
|
||||
if (collectors?.sessionMeta) collectSessionMeta(entry, collectors.sessionMeta)
|
||||
entries.push(compactEntry(entry))
|
||||
}
|
||||
if (!hasLines || entries.length === 0) return null
|
||||
return entries
|
||||
|
|
|
|||
|
|
@ -76,6 +76,21 @@ function mcpToolFromShellCommand(command: unknown): string | null {
|
|||
return `mcp__${server}__${tool}`
|
||||
}
|
||||
|
||||
// Count added/removed lines from a Codex `patch_apply_end` change's
|
||||
// `unified_diff`. A leading '+' is an added line and '-' a removed line; the
|
||||
// '+++'/'---' file headers and '@@' hunk headers are excluded. Numbers only —
|
||||
// the diff text is never stored. Rich-session-capture (capture-only).
|
||||
export function countUnifiedDiffLoc(diff: unknown): { added: number; removed: number } {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
if (typeof diff !== 'string') return { added, removed }
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) added++
|
||||
else if (line.startsWith('-') && !line.startsWith('---')) removed++
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
type CodexEntry = {
|
||||
type: string
|
||||
timestamp?: string
|
||||
|
|
@ -384,6 +399,11 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
let pendingToolSequence: ToolCall[][] = []
|
||||
let pendingUserMessage = ''
|
||||
let pendingOutputChars = 0
|
||||
// Rich-session-capture: edit LOC deltas and failed-patch count accumulated
|
||||
// across a turn's patch_apply_end events, flushed onto the turn's call.
|
||||
let pendingLocAdded = 0
|
||||
let pendingLocRemoved = 0
|
||||
let pendingEditFailed = 0
|
||||
let estCounter = 0
|
||||
let turnCounter = 0
|
||||
let currentTurnId = `${sessionId}:t0`
|
||||
|
|
@ -445,14 +465,21 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
pendingTools.push('Edit')
|
||||
const p = entry.payload as Record<string, unknown>
|
||||
const changes = p['changes']
|
||||
const filePaths = typeof changes === 'object' && changes ? Object.keys(changes as object) : []
|
||||
const changesObj = typeof changes === 'object' && changes ? changes as Record<string, unknown> : {}
|
||||
const filePaths = Object.keys(changesObj)
|
||||
if (filePaths.length > 0) {
|
||||
for (const fp of filePaths) {
|
||||
pendingToolSequence.push([{ tool: 'Edit', file: fp }])
|
||||
const diff = (changesObj[fp] as Record<string, unknown> | undefined)?.['unified_diff']
|
||||
const loc = countUnifiedDiffLoc(diff)
|
||||
pendingLocAdded += loc.added
|
||||
pendingLocRemoved += loc.removed
|
||||
}
|
||||
} else {
|
||||
pendingToolSequence.push([{ tool: 'Edit' }])
|
||||
}
|
||||
// Only an explicit failure counts; a missing `success` is treated as ok.
|
||||
if (p['success'] === false) pendingEditFailed++
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -508,7 +535,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
const timestamp = entry.timestamp ?? ''
|
||||
const dedupKey = `codex:${sessionId}:${timestamp}:est${estCounter++}`
|
||||
|
||||
if (seenKeys.has(dedupKey)) { pendingTools = []; pendingToolSequence = []; pendingUserMessage = ''; pendingOutputChars = 0; continue }
|
||||
if (seenKeys.has(dedupKey)) { pendingTools = []; pendingToolSequence = []; pendingUserMessage = ''; pendingOutputChars = 0; pendingLocAdded = 0; pendingLocRemoved = 0; pendingEditFailed = 0; continue }
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0)
|
||||
|
|
@ -534,12 +561,18 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
|
||||
userMessage: pendingUserMessage,
|
||||
sessionId,
|
||||
...(pendingLocAdded ? { locAdded: pendingLocAdded } : {}),
|
||||
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
|
||||
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
|
||||
})
|
||||
|
||||
pendingTools = []
|
||||
pendingToolSequence = []
|
||||
pendingUserMessage = ''
|
||||
pendingOutputChars = 0
|
||||
pendingLocAdded = 0
|
||||
pendingLocRemoved = 0
|
||||
pendingEditFailed = 0
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -644,12 +677,18 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
|
||||
userMessage: pendingUserMessage,
|
||||
sessionId,
|
||||
...(pendingLocAdded ? { locAdded: pendingLocAdded } : {}),
|
||||
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
|
||||
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
|
||||
})
|
||||
|
||||
pendingTools = []
|
||||
pendingToolSequence = []
|
||||
pendingUserMessage = ''
|
||||
pendingOutputChars = 0
|
||||
pendingLocAdded = 0
|
||||
pendingLocRemoved = 0
|
||||
pendingEditFailed = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ export type ParsedProviderCall = {
|
|||
timestamp: string
|
||||
speed: 'standard' | 'fast'
|
||||
deduplicationKey: string
|
||||
// Lines added/removed by this call's edits, counted from the provider's diff
|
||||
// records (Codex: `patch_apply_end.changes[*].unified_diff`). Numbers only;
|
||||
// omitted when zero. `editFailed` counts patches with `success === false`.
|
||||
// Rich-session-capture (capture-only; no report yet).
|
||||
locAdded?: number
|
||||
locRemoved?: number
|
||||
editFailed?: number
|
||||
turnId?: string
|
||||
toolSequence?: ToolCall[][]
|
||||
userMessage: string
|
||||
|
|
|
|||
|
|
@ -37,6 +37,19 @@ export type CachedCall = {
|
|||
project?: string
|
||||
projectPath?: string
|
||||
toolSequence?: ToolCall[][]
|
||||
// Rich-session-capture (capture-only; no report consumes these yet). All
|
||||
// optional and omitted at zero/false to keep the per-call cache cost minimal.
|
||||
// Lines added/removed by this call's edits, counted from tool-result diffs
|
||||
// (Claude structuredPatch / Codex unified_diff). Numbers only, never patch text.
|
||||
locAdded?: number
|
||||
locRemoved?: number
|
||||
// True only. Claude: a tool result was interrupted / user-modified its edit.
|
||||
interrupted?: boolean
|
||||
userModified?: boolean
|
||||
// Claude: count of this call's tool results flagged is_error. Omitted at 0.
|
||||
toolErrors?: number
|
||||
// Codex: count of this call's patch applications with success === false.
|
||||
editFailed?: number
|
||||
}
|
||||
|
||||
export type CachedTurn = {
|
||||
|
|
@ -44,6 +57,10 @@ export type CachedTurn = {
|
|||
sessionId: string
|
||||
userMessage: string
|
||||
calls: CachedCall[]
|
||||
// Claude: git branch for this turn, stored only when it differs from the
|
||||
// previous turn's branch (a report carries the last stored value forward).
|
||||
// Rich-session-capture; optional, Claude only.
|
||||
gitBranch?: string
|
||||
}
|
||||
|
||||
export type FileFingerprint = {
|
||||
|
|
@ -69,6 +86,14 @@ export type CachedFile = {
|
|||
// is re-parsed only when the file changes (fingerprint differs). Carries no
|
||||
// turns, so it contributes no usage. (issue #441 follow-up)
|
||||
failed?: boolean
|
||||
// Rich-session-capture, Claude session-level (capture-only; no report yet).
|
||||
// `title` is the LAST `ai-title` entry's text; `prLinks` accumulates every
|
||||
// `pr-link` entry's URL. `isSidechain` is true when any entry is a sidechain:
|
||||
// parentUuid references an intra-file entry uuid, not another session id, so it
|
||||
// cannot link sessions — only the boolean marker is reliable. All optional.
|
||||
title?: string
|
||||
prLinks?: string[]
|
||||
isSidechain?: boolean
|
||||
}
|
||||
|
||||
export type ProviderSection = {
|
||||
|
|
@ -143,14 +168,21 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet<string> = new Set(['copilot'])
|
|||
// re-parse, which lands the flag too, and durable orphans now survive
|
||||
// fingerprint changes (the carry-forward in getOrCreateProviderSection).
|
||||
export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
||||
claude: 'advisor-usage-v1-skills',
|
||||
// rich-session-capture-v1: parse-time capture of per-turn gitBranch, per-call
|
||||
// LOC deltas / interruptions / userModified / toolErrors, and session-level
|
||||
// title / prLinks / isSidechain. Forces one re-parse so cached sessions gain
|
||||
// the new optional fields.
|
||||
claude: 'advisor-usage-v1-skills-rich-capture-v1',
|
||||
cline: 'worktree-project-grouping-v1',
|
||||
codewhale: 'aggregate-session-v1-est-cost',
|
||||
// Bump when the Codex parser changes attribution so unchanged, already-cached
|
||||
// session files re-parse (session-cache.json serves them without invoking the
|
||||
// provider parser otherwise). Covers native mcp_tool_call_end (#513) and
|
||||
// CLI-wrapped `mcp-cli call` (#478) MCP attribution.
|
||||
codex: 'mcp-attribution-v2-est-cost',
|
||||
// rich-session-capture-v1: per-call LOC deltas + editFailed from
|
||||
// patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in
|
||||
// lockstep so the pre-session-cache layer re-parses too.)
|
||||
codex: 'mcp-attribution-v2-est-cost-rich-capture-v1',
|
||||
cursor: 'composer-anchored-crediting-v1-est-cost',
|
||||
'cursor-agent': 'workspaceless-transcript-v1',
|
||||
copilot: 'cli-shutdown-cost-v1-skills',
|
||||
|
|
@ -273,6 +305,12 @@ function validateCall(c: unknown): c is CachedCall {
|
|||
&& isOptionalString(o['project'])
|
||||
&& isOptionalString(o['projectPath'])
|
||||
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s))))
|
||||
&& isOptionalNum(o['locAdded'])
|
||||
&& isOptionalNum(o['locRemoved'])
|
||||
&& isOptionalBool(o['interrupted'])
|
||||
&& isOptionalBool(o['userModified'])
|
||||
&& isOptionalNum(o['toolErrors'])
|
||||
&& isOptionalNum(o['editFailed'])
|
||||
&& validateUsage(o['usage'])
|
||||
}
|
||||
|
||||
|
|
@ -282,6 +320,7 @@ function validateTurn(t: unknown): t is CachedTurn {
|
|||
return typeof o['timestamp'] === 'string'
|
||||
&& typeof o['sessionId'] === 'string'
|
||||
&& typeof o['userMessage'] === 'string'
|
||||
&& isOptionalString(o['gitBranch'])
|
||||
&& Array.isArray(o['calls'])
|
||||
&& (o['calls'] as unknown[]).every(validateCall)
|
||||
}
|
||||
|
|
@ -294,6 +333,9 @@ function validateCachedFile(f: unknown): f is CachedFile {
|
|||
&& isOptionalString(o['canonicalCwd'])
|
||||
&& isOptionalString(o['canonicalProjectName'])
|
||||
&& isStringArray(o['mcpInventory'])
|
||||
&& isOptionalString(o['title'])
|
||||
&& (o['prLinks'] === undefined || isStringArray(o['prLinks']))
|
||||
&& isOptionalBool(o['isSidechain'])
|
||||
&& Array.isArray(o['turns'])
|
||||
&& (o['turns'] as unknown[]).every(validateTurn)
|
||||
}
|
||||
|
|
|
|||
17
src/types.ts
17
src/types.ts
|
|
@ -90,6 +90,10 @@ export type ParsedTurn = {
|
|||
assistantCalls: ParsedApiCall[]
|
||||
timestamp: string
|
||||
sessionId: string
|
||||
// Claude Code: the git branch active for this turn (top-level `gitBranch` on
|
||||
// the turn's entries). Captured for cost-per-branch reporting; deduped at the
|
||||
// cache boundary (stored per-turn only when it changes). Optional; Claude only.
|
||||
gitBranch?: string
|
||||
}
|
||||
|
||||
export type ParsedApiCall = {
|
||||
|
|
@ -122,6 +126,19 @@ export type ParsedApiCall = {
|
|||
/// across the parser/cache boundary. Aggregates roll the estimated portion up
|
||||
/// as `estimatedCostUSD`; it is display/metadata only and never changes totals.
|
||||
isEstimated?: boolean
|
||||
/// Lines added/removed by this call's edits, counted from tool-result diffs
|
||||
/// (Claude: `toolUseResult.structuredPatch`). Numbers only, never patch text;
|
||||
/// omitted when zero. Rich-session-capture (capture-only; no report yet).
|
||||
locAdded?: number
|
||||
locRemoved?: number
|
||||
/// True only when at least one of this call's tool results was interrupted or
|
||||
/// had its edit modified by the user (Claude `toolUseResult.interrupted` /
|
||||
/// `userModified`). Omitted when false.
|
||||
interrupted?: boolean
|
||||
userModified?: boolean
|
||||
/// Count of this call's tool results flagged `is_error` (Claude tool_result
|
||||
/// blocks). Bash stderr alone is NOT counted (warnings go there). Omitted at 0.
|
||||
toolErrors?: number
|
||||
}
|
||||
|
||||
export type ToolCall = {
|
||||
|
|
|
|||
115
tests/codex-rich-capture.test.ts
Normal file
115
tests/codex-rich-capture.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createCodexProvider, countUnifiedDiffLoc } from '../src/providers/codex.js'
|
||||
import type { ParsedProviderCall } from '../src/providers/types.js'
|
||||
|
||||
// ── unified-diff LOC counting ──────────────────────────────────────────
|
||||
|
||||
describe('countUnifiedDiffLoc', () => {
|
||||
it('counts +/- content lines and ignores @@ / +++ / --- headers', () => {
|
||||
// Shape copied from a real ~/.codex patch_apply_end change (sanitized).
|
||||
const diff = '@@ -83,3 +83,3 @@\n \n-SCHEMA_VERSION = 12\n+SCHEMA_VERSION = 13\n # cap\n@@ -107,2 +107,6 @@\n \n+class NewError(AssertionError):\n+ pass\n'
|
||||
expect(countUnifiedDiffLoc(diff)).toEqual({ added: 3, removed: 1 })
|
||||
})
|
||||
|
||||
it('excludes git-style file headers', () => {
|
||||
const diff = '--- a/file.ts\n+++ b/file.ts\n@@ -1 +1 @@\n-old\n+new\n'
|
||||
expect(countUnifiedDiffLoc(diff)).toEqual({ added: 1, removed: 1 })
|
||||
})
|
||||
|
||||
it('returns zero for non-string input', () => {
|
||||
expect(countUnifiedDiffLoc(undefined)).toEqual({ added: 0, removed: 0 })
|
||||
expect(countUnifiedDiffLoc(null)).toEqual({ added: 0, removed: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
// ── parser-level: patch_apply_end LOC + failed-edit attribution ─────────
|
||||
|
||||
let tmpDir: string
|
||||
beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codex-rich-')) })
|
||||
afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }) })
|
||||
|
||||
function line(obj: unknown): string { return JSON.stringify(obj) }
|
||||
|
||||
async function writeSession(dir: string, date: string, filename: string, lines: string[]): Promise<string> {
|
||||
const [year, month, day] = date.split('-')
|
||||
const sessionDir = join(dir, 'sessions', year!, month!, day!)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
const filePath = join(sessionDir, filename)
|
||||
await writeFile(filePath, lines.join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function parseAll(filePath: string): Promise<ParsedProviderCall[]> {
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const parser = provider.createSessionParser(source, new Set())
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
return calls
|
||||
}
|
||||
|
||||
const patchApplyEnd = (opts: { changes: Record<string, string>; success?: boolean }) => line({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:00:30Z',
|
||||
payload: {
|
||||
type: 'patch_apply_end',
|
||||
success: opts.success ?? true,
|
||||
changes: Object.fromEntries(Object.entries(opts.changes).map(([f, d]) => [f, { type: 'update', unified_diff: d, move_path: null }])),
|
||||
},
|
||||
})
|
||||
|
||||
const tokenCount = () => line({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:01:00Z',
|
||||
payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, total_token_usage: { total_tokens: 150 } } },
|
||||
})
|
||||
|
||||
describe('codex parser - patch_apply_end capture', () => {
|
||||
it('attaches locAdded/locRemoved summed across changed files', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-loc.jsonl', [
|
||||
line({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { cwd: '/Users/t/p', originator: 'codex-cli', session_id: 'sx', model: 'gpt-5.3-codex' } }),
|
||||
line({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'edit files' }] } }),
|
||||
patchApplyEnd({ changes: {
|
||||
'/Users/t/p/a.ts': '@@ -1 +1,2 @@\n-old\n+new\n+extra\n',
|
||||
'/Users/t/p/b.ts': '@@ -1,2 +1 @@\n-x\n-y\n+z\n',
|
||||
} }),
|
||||
tokenCount(),
|
||||
])
|
||||
const calls = await parseAll(filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.locAdded).toBe(3)
|
||||
expect(calls[0]!.locRemoved).toBe(3)
|
||||
expect(calls[0]!.editFailed).toBeUndefined()
|
||||
})
|
||||
|
||||
it('counts a failed patch application', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-fail.jsonl', [
|
||||
line({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { cwd: '/Users/t/p', originator: 'codex-cli', session_id: 'sy', model: 'gpt-5.3-codex' } }),
|
||||
line({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'edit' }] } }),
|
||||
patchApplyEnd({ success: false, changes: { '/Users/t/p/a.ts': '@@ -1 +1 @@\n-old\n+new\n' } }),
|
||||
tokenCount(),
|
||||
])
|
||||
const calls = await parseAll(filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.editFailed).toBe(1)
|
||||
expect(calls[0]!.locAdded).toBe(1)
|
||||
expect(calls[0]!.locRemoved).toBe(1)
|
||||
})
|
||||
|
||||
it('omits LOC fields for a turn with no patch', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-nopatch.jsonl', [
|
||||
line({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { cwd: '/Users/t/p', originator: 'codex-cli', session_id: 'sz', model: 'gpt-5.3-codex' } }),
|
||||
line({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] } }),
|
||||
tokenCount(),
|
||||
])
|
||||
const calls = await parseAll(filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.locAdded).toBeUndefined()
|
||||
expect(calls[0]!.locRemoved).toBeUndefined()
|
||||
expect(calls[0]!.editFailed).toBeUndefined()
|
||||
})
|
||||
})
|
||||
213
tests/parser-rich-capture.test.ts
Normal file
213
tests/parser-rich-capture.test.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
117
tests/session-cache-rich-capture.test.ts
Normal file
117
tests/session-cache-rich-capture.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { rm, writeFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import {
|
||||
CACHE_VERSION,
|
||||
type CachedCall,
|
||||
type CachedFile,
|
||||
type SessionCache,
|
||||
emptyCache,
|
||||
loadCache,
|
||||
saveCache,
|
||||
sessionCachePath,
|
||||
} from '../src/session-cache.js'
|
||||
|
||||
const TMP_DIR = join(tmpdir(), `codeburn-rich-cache-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
|
||||
beforeEach(() => { process.env['CODEBURN_CACHE_DIR'] = TMP_DIR })
|
||||
afterEach(async () => { if (existsSync(TMP_DIR)) await rm(TMP_DIR, { recursive: true }) })
|
||||
|
||||
function richCall(): CachedCall {
|
||||
return {
|
||||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
usage: {
|
||||
inputTokens: 100, outputTokens: 50, cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0,
|
||||
},
|
||||
speed: 'standard',
|
||||
timestamp: '2026-07-01T10:00:00Z',
|
||||
tools: ['Edit'],
|
||||
bashCommands: [],
|
||||
skills: [],
|
||||
subagentTypes: [],
|
||||
deduplicationKey: 'm1',
|
||||
locAdded: 12,
|
||||
locRemoved: 4,
|
||||
interrupted: true,
|
||||
userModified: true,
|
||||
toolErrors: 2,
|
||||
editFailed: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function richFile(): CachedFile {
|
||||
return {
|
||||
fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 },
|
||||
mcpInventory: [],
|
||||
title: 'A rich session',
|
||||
prLinks: ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2'],
|
||||
isSidechain: true,
|
||||
turns: [
|
||||
{ timestamp: '2026-07-01T10:00:00Z', sessionId: 's1', userMessage: 'hi', gitBranch: 'feature/x', calls: [richCall()] },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('session cache round-trip for rich-capture fields', () => {
|
||||
it('preserves per-call, per-turn, and per-session fields through save+load', async () => {
|
||||
const cache: SessionCache = {
|
||||
...emptyCache(),
|
||||
providers: { claude: { envFingerprint: 'fp', files: { '/x/s1.jsonl': richFile() } } },
|
||||
}
|
||||
await saveCache(cache)
|
||||
|
||||
const loaded = await loadCache()
|
||||
const file = loaded.providers['claude']!.files['/x/s1.jsonl']!
|
||||
expect(file.title).toBe('A rich session')
|
||||
expect(file.prLinks).toEqual(['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2'])
|
||||
expect(file.isSidechain).toBe(true)
|
||||
|
||||
const turn = file.turns[0]!
|
||||
expect(turn.gitBranch).toBe('feature/x')
|
||||
|
||||
const call = turn.calls[0]!
|
||||
expect(call.locAdded).toBe(12)
|
||||
expect(call.locRemoved).toBe(4)
|
||||
expect(call.interrupted).toBe(true)
|
||||
expect(call.userModified).toBe(true)
|
||||
expect(call.toolErrors).toBe(2)
|
||||
expect(call.editFailed).toBe(1)
|
||||
})
|
||||
|
||||
it('still loads an old cache written without any rich-capture fields', async () => {
|
||||
const oldCall = { ...richCall() }
|
||||
for (const k of ['locAdded', 'locRemoved', 'interrupted', 'userModified', 'toolErrors', 'editFailed'] as const) {
|
||||
delete (oldCall as Record<string, unknown>)[k]
|
||||
}
|
||||
const oldCache: SessionCache = {
|
||||
version: CACHE_VERSION,
|
||||
complete: true,
|
||||
providers: {
|
||||
claude: {
|
||||
envFingerprint: 'fp',
|
||||
files: {
|
||||
'/x/old.jsonl': {
|
||||
fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 },
|
||||
mcpInventory: [],
|
||||
turns: [{ timestamp: '2026-07-01T10:00:00Z', sessionId: 's1', userMessage: 'hi', calls: [oldCall] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if (!existsSync(TMP_DIR)) await mkdir(TMP_DIR, { recursive: true })
|
||||
await writeFile(sessionCachePath(), JSON.stringify(oldCache), 'utf-8')
|
||||
|
||||
const loaded = await loadCache()
|
||||
const call = loaded.providers['claude']!.files['/x/old.jsonl']!.turns[0]!.calls[0]!
|
||||
expect(call.deduplicationKey).toBe('m1')
|
||||
expect(call.locAdded).toBeUndefined()
|
||||
expect(call.editFailed).toBeUndefined()
|
||||
expect(loaded.providers['claude']!.files['/x/old.jsonl']!.title).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue