Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/codex-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
196 changes: 186 additions & 10 deletions src/parser.ts

Large diffs are not rendered by default.

43 changes: 41 additions & 2 deletions src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
}

Expand Down
7 changes: 7 additions & 0 deletions src/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 44 additions & 2 deletions src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,30 @@ 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 = {
timestamp: string
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 = {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'])
}

Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down
17 changes: 17 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
115 changes: 115 additions & 0 deletions tests/codex-rich-capture.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading