mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-04 05:41:29 +00:00
Merge pull request #805 from ihearttokyo/agent/fix-daily-history-scan
Some checks failed
CI / semgrep (push) Has been cancelled
Some checks failed
CI / semgrep (push) Has been cancelled
feat(codex): add tool-excluded active Tok/s metrics
This commit is contained in:
commit
146037bfd5
15 changed files with 1425 additions and 13 deletions
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Added (CLI)
|
||||
- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)
|
||||
|
||||
### Fixed (CLI)
|
||||
- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)
|
||||
### Fixed
|
||||
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ 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.
|
||||
// v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from
|
||||
// v6/v7: 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 = 7
|
||||
// v8: persist native MCP timing and compact invocation attribution.
|
||||
const CODEX_CACHE_VERSION = 8
|
||||
const CACHE_FILE = 'codex-results.json'
|
||||
|
||||
type FileFingerprint = { mtimeMs: number; sizeBytes: number }
|
||||
|
|
|
|||
521
src/codex-throughput.ts
Normal file
521
src/codex-throughput.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import { open, stat } from 'node:fs/promises'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
|
||||
export type CodexThroughputPoint = {
|
||||
timestamp: string
|
||||
model?: string
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
generatedTokens: number
|
||||
taskGeneratedTokens?: number
|
||||
elapsedSeconds?: number
|
||||
generatedTokensPerSecond?: number
|
||||
activeDurationSeconds?: number
|
||||
activeGeneratedTokensPerSecond?: number
|
||||
toolWaitSeconds?: number
|
||||
}
|
||||
|
||||
type TokenUsage = {
|
||||
output_tokens?: number
|
||||
reasoning_output_tokens?: number
|
||||
total_tokens?: number
|
||||
}
|
||||
|
||||
type RolloutLine = {
|
||||
type?: string
|
||||
timestamp?: string
|
||||
payload?: {
|
||||
type?: string
|
||||
turn_id?: string
|
||||
call_id?: string
|
||||
started_at?: number
|
||||
duration_ms?: number
|
||||
duration?: { secs?: number; nanos?: number } | string
|
||||
model?: string
|
||||
forked_from_id?: string
|
||||
info?: {
|
||||
last_token_usage?: TokenUsage
|
||||
total_token_usage?: TokenUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CHUNK_BYTES = 64 * 1024
|
||||
const MAX_PENDING_LINE_CHARS = 4 * 1024 * 1024
|
||||
const TRUNCATION_MARKER = '__CODEBURN_TRUNCATED_LINE__'
|
||||
|
||||
function rawString(source: string, field: string): string | undefined {
|
||||
const match = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(source)
|
||||
if (!match) return undefined
|
||||
try { return JSON.parse(`"${match[1]}"`) as string } catch { return undefined }
|
||||
}
|
||||
|
||||
function rawNumber(source: string, field: string): number | undefined {
|
||||
const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(source)
|
||||
if (!match) return undefined
|
||||
const value = Number(match[1])
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function compactUsage(source: string, field: 'last_token_usage' | 'total_token_usage'): TokenUsage | undefined {
|
||||
const index = source.indexOf(`"${field}"`)
|
||||
if (index < 0) return undefined
|
||||
const body = source.slice(index, index + 4096)
|
||||
return {
|
||||
output_tokens: rawNumber(body, 'output_tokens'),
|
||||
reasoning_output_tokens: rawNumber(body, 'reasoning_output_tokens'),
|
||||
total_tokens: rawNumber(body, 'total_tokens'),
|
||||
}
|
||||
}
|
||||
|
||||
function parseRawDurationValue(value: string): number | undefined {
|
||||
const objectMatch = /^\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(value)
|
||||
if (objectMatch) {
|
||||
const seconds = Number(objectMatch[1])
|
||||
const nanos = Number(objectMatch[2])
|
||||
if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6
|
||||
}
|
||||
const stringMatch = /^\s*"(\d+(?:\.\d+)?)(ms|s)?"/.exec(value)
|
||||
if (stringMatch) {
|
||||
const parsed = Number(stringMatch[1])
|
||||
if (Number.isFinite(parsed)) return parsed * (stringMatch[2] === 's' ? 1000 : 1)
|
||||
}
|
||||
const numberMatch = /^\s*(-?\d+(?:\.\d+)?)/.exec(value)
|
||||
if (numberMatch) {
|
||||
const parsed = Number(numberMatch[1])
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function durationMs(payload: RolloutLine['payload']): number | undefined {
|
||||
if (!payload) return undefined
|
||||
if (typeof payload.duration_ms === 'number' && Number.isFinite(payload.duration_ms)) return payload.duration_ms
|
||||
if (typeof payload.duration === 'object' && payload.duration) {
|
||||
const seconds = payload.duration.secs
|
||||
const nanos = payload.duration.nanos
|
||||
if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) {
|
||||
return seconds * 1000 + nanos / 1e6
|
||||
}
|
||||
}
|
||||
if (typeof payload.duration === 'string') {
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(payload.duration.trim())
|
||||
if (match) return Number(match[1]) * (match[2] === 's' ? 1000 : 1)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mergeToolIntervals(intervals: Array<[number, number]>, durationMs: number, taskStartedAt?: number, taskCompletedAt?: number): number {
|
||||
const windowStart = taskStartedAt ?? (taskCompletedAt !== undefined ? taskCompletedAt - durationMs : undefined)
|
||||
const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined
|
||||
const clipped = intervals.map(([start, end]) => [
|
||||
windowStart !== undefined ? Math.max(start, windowStart) : start,
|
||||
windowEnd !== undefined ? Math.min(end, windowEnd) : end,
|
||||
] as [number, number]).filter(([start, end]) => end > start)
|
||||
const merged = clipped.sort((a, b) => a[0] - b[0]).reduce<Array<[number, number]>>((result, interval) => {
|
||||
const previous = result.at(-1)
|
||||
if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1])
|
||||
else result.push([...interval])
|
||||
return result
|
||||
}, [])
|
||||
return Math.min(durationMs, merged.reduce((total, [start, end]) => total + end - start, 0))
|
||||
}
|
||||
|
||||
function parseLine(line: string): RolloutLine | null {
|
||||
const payloadStart = line.indexOf('"payload"')
|
||||
const payloadHead = payloadStart >= 0 ? line.slice(payloadStart) : line
|
||||
if (line.length > 256 * 1024 || line.startsWith(TRUNCATION_MARKER)) {
|
||||
const payloadType = rawString(payloadHead, 'type')
|
||||
const infoStart = payloadHead.indexOf('"info"')
|
||||
const info = infoStart >= 0 ? payloadHead.slice(infoStart) : ''
|
||||
return {
|
||||
type: rawString(line, 'type'),
|
||||
timestamp: rawString(line, 'timestamp'),
|
||||
payload: {
|
||||
type: payloadType,
|
||||
turn_id: rawString(payloadHead, 'turn_id'),
|
||||
call_id: rawString(payloadHead, 'call_id'),
|
||||
started_at: rawNumber(payloadHead, 'started_at'),
|
||||
duration_ms: rawNumber(payloadHead, 'duration_ms'),
|
||||
duration: rawString(payloadHead, 'duration') ?? (rawNumber(payloadHead, 'secs') !== undefined
|
||||
? { secs: rawNumber(payloadHead, 'secs'), nanos: rawNumber(payloadHead, 'nanos') }
|
||||
: undefined),
|
||||
model: rawString(payloadHead, 'model'),
|
||||
forked_from_id: rawString(payloadHead, 'forked_from_id'),
|
||||
info: {
|
||||
last_token_usage: compactUsage(info, 'last_token_usage'),
|
||||
total_token_usage: compactUsage(info, 'total_token_usage'),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(line) as RolloutLine
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate generated tokens/sec from a Codex rollout's persisted checkpoints.
|
||||
* Codex JSONL has no per-token timestamps, so this is deliberately a
|
||||
* checkpoint-to-checkpoint estimate, not live decode speed.
|
||||
*/
|
||||
type ThroughputState = {
|
||||
model?: string
|
||||
previousTotal?: number
|
||||
previousOutput: number
|
||||
previousReasoning: number
|
||||
previousTimestamp?: number
|
||||
currentTaskGenerated: number
|
||||
currentTaskToolIntervals: Array<[number, number]>
|
||||
currentTaskStartedAt?: number
|
||||
toolStarts: Map<string, number>
|
||||
latestPoint?: CodexThroughputPoint
|
||||
points: CodexThroughputPoint[]
|
||||
forkCutoffMs?: number
|
||||
}
|
||||
|
||||
function newThroughputState(): ThroughputState {
|
||||
return {
|
||||
previousOutput: 0,
|
||||
previousReasoning: 0,
|
||||
currentTaskGenerated: 0,
|
||||
currentTaskToolIntervals: [],
|
||||
toolStarts: new Map(),
|
||||
points: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally parses a rollout. Watch mode feeds only newly appended bytes
|
||||
* to this reader, so a growing JSONL file is not reparsed from byte zero.
|
||||
*/
|
||||
export class CodexThroughputReader {
|
||||
private offset = 0
|
||||
private pending = ''
|
||||
private decoder = new StringDecoder('utf8')
|
||||
private pendingDurationMs: number | undefined
|
||||
private scanDepth = 0
|
||||
private scanPayloadDepth: number | undefined
|
||||
private scanInString = false
|
||||
private scanEscape = false
|
||||
private scanString = ''
|
||||
private scanLastString = ''
|
||||
private scanAwaitingColon = false
|
||||
private scanCurrentKey: string | undefined
|
||||
private scanCapture: { mode: 'string' | 'object' | 'primitive'; text: string; depth: number } | undefined
|
||||
private state = newThroughputState()
|
||||
|
||||
reset(): void {
|
||||
this.offset = 0
|
||||
this.pending = ''
|
||||
this.decoder = new StringDecoder('utf8')
|
||||
this.pendingDurationMs = undefined
|
||||
this.scanDepth = 0
|
||||
this.scanPayloadDepth = undefined
|
||||
this.scanInString = false
|
||||
this.scanEscape = false
|
||||
this.scanString = ''
|
||||
this.scanLastString = ''
|
||||
this.scanAwaitingColon = false
|
||||
this.scanCurrentKey = undefined
|
||||
this.scanCapture = undefined
|
||||
this.state = newThroughputState()
|
||||
}
|
||||
|
||||
private finishDurationCapture(): void {
|
||||
if (!this.scanCapture) return
|
||||
const value = this.scanCapture.mode === 'string' ? `"${this.scanCapture.text}"` : this.scanCapture.text
|
||||
const parsed = parseRawDurationValue(value)
|
||||
if (parsed !== undefined && this.pendingDurationMs === undefined) this.pendingDurationMs = parsed
|
||||
this.scanCapture = undefined
|
||||
}
|
||||
|
||||
private scanDurationSegment(source: string): void {
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const char = source[i]!
|
||||
if (this.scanInString) {
|
||||
if (this.scanEscape) {
|
||||
this.scanEscape = false
|
||||
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
|
||||
else if (this.scanCapture?.mode === 'string') this.scanCapture.text += char
|
||||
else this.scanString += char
|
||||
continue
|
||||
}
|
||||
if (char === '\\') {
|
||||
this.scanEscape = true
|
||||
if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
|
||||
this.scanInString = false
|
||||
if (this.scanCapture?.mode === 'string') this.finishDurationCapture()
|
||||
else if (this.scanCapture?.mode === 'object') {
|
||||
this.scanAwaitingColon = false
|
||||
this.scanCurrentKey = undefined
|
||||
} else {
|
||||
this.scanLastString = this.scanString
|
||||
this.scanAwaitingColon = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char
|
||||
else this.scanString += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.scanCapture?.mode === 'primitive') {
|
||||
if (char === ',' || char === '}' || char === ']') this.finishDurationCapture()
|
||||
else { this.scanCapture.text += char; continue }
|
||||
}
|
||||
if (this.scanAwaitingColon) {
|
||||
if (/\s/.test(char)) continue
|
||||
if (char === ':') {
|
||||
this.scanCurrentKey = this.scanLastString
|
||||
this.scanAwaitingColon = false
|
||||
continue
|
||||
}
|
||||
this.scanAwaitingColon = false
|
||||
}
|
||||
if (char === '"') {
|
||||
this.scanString = ''
|
||||
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
|
||||
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) {
|
||||
this.scanCapture = { mode: 'string', text: '', depth: this.scanDepth }
|
||||
this.scanCurrentKey = undefined
|
||||
}
|
||||
this.scanInString = true
|
||||
continue
|
||||
}
|
||||
if (char === '{' || char === '[') {
|
||||
if (this.scanCurrentKey === 'payload' && char === '{' && this.scanPayloadDepth === undefined) {
|
||||
this.scanPayloadDepth = this.scanDepth + 1
|
||||
}
|
||||
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) {
|
||||
this.scanCapture = { mode: 'object', text: char, depth: this.scanDepth + 1 }
|
||||
this.scanCurrentKey = undefined
|
||||
} else if (this.scanCapture?.mode === 'object') {
|
||||
this.scanCapture.text += char
|
||||
}
|
||||
this.scanDepth++
|
||||
continue
|
||||
}
|
||||
if (char === '}' || char === ']') {
|
||||
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
|
||||
this.scanDepth = Math.max(0, this.scanDepth - 1)
|
||||
if (this.scanCapture?.mode === 'object' && this.scanDepth < this.scanCapture.depth) this.finishDurationCapture()
|
||||
continue
|
||||
}
|
||||
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth && !/\s/.test(char)) {
|
||||
this.scanCapture = { mode: 'primitive', text: char, depth: this.scanDepth }
|
||||
this.scanCurrentKey = undefined
|
||||
continue
|
||||
}
|
||||
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
|
||||
}
|
||||
}
|
||||
|
||||
private processLine(line: string, durationOverride?: number): void {
|
||||
const entry = parseLine(line)
|
||||
if (!entry) return
|
||||
if (durationOverride !== undefined && (line.startsWith(TRUNCATION_MARKER) || line.length > 256 * 1024) && entry.type === 'event_msg' && (entry.payload?.type === 'mcp_tool_call_end' || entry.payload?.type === 'task_complete')) {
|
||||
entry.payload = { ...entry.payload, duration_ms: durationOverride }
|
||||
}
|
||||
const state = this.state
|
||||
if (entry.type === 'session_meta') {
|
||||
if (entry.payload?.model) state.model = entry.payload.model
|
||||
if (entry.payload?.forked_from_id && entry.timestamp) {
|
||||
const timestamp = Date.parse(entry.timestamp)
|
||||
if (Number.isFinite(timestamp)) state.forkCutoffMs = timestamp + 5000
|
||||
}
|
||||
return
|
||||
}
|
||||
if (entry.type === 'turn_context' && entry.payload?.model) state.model = entry.payload.model
|
||||
const entryTimestamp = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
const isForkReplay = state.forkCutoffMs !== undefined && Number.isFinite(entryTimestamp) && entryTimestamp < state.forkCutoffMs
|
||||
if (isForkReplay && (
|
||||
entry.payload?.type === 'task_started' ||
|
||||
entry.payload?.type === 'task_complete' ||
|
||||
entry.payload?.type === 'function_call' ||
|
||||
entry.payload?.type === 'function_call_output' ||
|
||||
entry.payload?.type === 'custom_tool_call' ||
|
||||
entry.payload?.type === 'custom_tool_call_output' ||
|
||||
entry.payload?.type === 'mcp_tool_call_end' ||
|
||||
entry.payload?.type === 'patch_apply_end' ||
|
||||
entry.payload?.type === 'token_count'
|
||||
)) return
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') {
|
||||
state.currentTaskGenerated = 0
|
||||
state.currentTaskToolIntervals = []
|
||||
const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
state.currentTaskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined
|
||||
state.toolStarts.clear()
|
||||
}
|
||||
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call') && entry.payload.call_id && entry.timestamp) {
|
||||
const started = Date.parse(entry.timestamp)
|
||||
if (Number.isFinite(started)) state.toolStarts.set(entry.payload.call_id, started)
|
||||
}
|
||||
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output') && entry.payload.call_id && entry.timestamp) {
|
||||
const ended = Date.parse(entry.timestamp)
|
||||
const started = state.toolStarts.get(entry.payload.call_id)
|
||||
if (started !== undefined && Number.isFinite(ended) && ended > started) state.currentTaskToolIntervals.push([started, ended])
|
||||
state.toolStarts.delete(entry.payload.call_id)
|
||||
}
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end' && entry.timestamp) {
|
||||
const ended = Date.parse(entry.timestamp)
|
||||
const elapsed = durationMs(entry.payload)
|
||||
if (Number.isFinite(ended) && elapsed !== undefined && elapsed > 0) state.currentTaskToolIntervals.push([ended - elapsed, ended])
|
||||
}
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') {
|
||||
const taskDurationMs = durationMs(entry.payload)
|
||||
if (state.latestPoint && typeof taskDurationMs === 'number' && taskDurationMs > 0 && state.currentTaskGenerated > 0) {
|
||||
state.latestPoint.taskGeneratedTokens = state.currentTaskGenerated
|
||||
const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : undefined
|
||||
const toolWaitMs = mergeToolIntervals(state.currentTaskToolIntervals, taskDurationMs, state.currentTaskStartedAt, Number.isFinite(completedAt) ? completedAt : undefined)
|
||||
const activeMs = taskDurationMs - toolWaitMs
|
||||
if (activeMs > 0) {
|
||||
state.latestPoint.activeDurationSeconds = activeMs / 1000
|
||||
state.latestPoint.toolWaitSeconds = toolWaitMs / 1000
|
||||
state.latestPoint.activeGeneratedTokensPerSecond = state.currentTaskGenerated / (activeMs / 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') return
|
||||
const info = entry.payload.info
|
||||
if (!info || !entry.timestamp) return
|
||||
const last = info.last_token_usage
|
||||
const total = info.total_token_usage
|
||||
const cumulative = total?.total_tokens
|
||||
if (cumulative !== undefined && cumulative === state.previousTotal) return
|
||||
let outputTokens = last?.output_tokens ?? 0
|
||||
let reasoningTokens = last?.reasoning_output_tokens ?? 0
|
||||
if (!last && total && cumulative !== undefined && state.previousTotal !== undefined) {
|
||||
outputTokens = Math.max(0, (total.output_tokens ?? 0) - state.previousOutput)
|
||||
reasoningTokens = Math.max(0, (total.reasoning_output_tokens ?? 0) - state.previousReasoning)
|
||||
}
|
||||
if (cumulative !== undefined) {
|
||||
state.previousTotal = cumulative
|
||||
state.previousOutput = total?.output_tokens ?? state.previousOutput
|
||||
state.previousReasoning = total?.reasoning_output_tokens ?? state.previousReasoning
|
||||
}
|
||||
const generatedTokens = outputTokens + reasoningTokens
|
||||
if (generatedTokens <= 0) return
|
||||
const timestampMs = Date.parse(entry.timestamp)
|
||||
if (!Number.isFinite(timestampMs)) return
|
||||
const point: CodexThroughputPoint = {
|
||||
timestamp: entry.timestamp,
|
||||
model: state.model,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
generatedTokens,
|
||||
}
|
||||
state.currentTaskGenerated += generatedTokens
|
||||
state.latestPoint = point
|
||||
if (state.previousTimestamp !== undefined && timestampMs > state.previousTimestamp) {
|
||||
const elapsedSeconds = (timestampMs - state.previousTimestamp) / 1000
|
||||
point.elapsedSeconds = elapsedSeconds
|
||||
point.generatedTokensPerSecond = generatedTokens / elapsedSeconds
|
||||
}
|
||||
state.previousTimestamp = timestampMs
|
||||
state.points.push(point)
|
||||
if (state.points.length > 10000) state.points.splice(0, state.points.length - 10000)
|
||||
}
|
||||
|
||||
async update(filePath: string, limit = 10, finalize = false): Promise<CodexThroughputPoint[]> {
|
||||
const info = await stat(filePath)
|
||||
if (info.size < this.offset) this.reset()
|
||||
const bytesToRead = info.size - this.offset
|
||||
if (bytesToRead > 0) {
|
||||
const file = await open(filePath, 'r')
|
||||
try {
|
||||
let position = this.offset
|
||||
while (position < info.size) {
|
||||
const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, info.size - position))
|
||||
const { bytesRead } = await file.read(buffer, 0, buffer.length, position)
|
||||
if (bytesRead === 0) break
|
||||
position += bytesRead
|
||||
this.offset = position
|
||||
let chunk = this.decoder.write(buffer.subarray(0, bytesRead))
|
||||
while (chunk.length > 0) {
|
||||
const newlineIndex = chunk.search(/\r?\n/)
|
||||
const segment = newlineIndex >= 0 ? chunk.slice(0, newlineIndex) : chunk
|
||||
this.pending += segment
|
||||
this.scanDurationSegment(segment)
|
||||
if (newlineIndex < 0) break
|
||||
const newlineLength = chunk[newlineIndex] === '\r' ? 2 : 1
|
||||
const line = this.pending
|
||||
const durationOverride = this.pendingDurationMs
|
||||
this.pending = ''
|
||||
this.pendingDurationMs = undefined
|
||||
this.scanDepth = 0
|
||||
this.scanPayloadDepth = undefined
|
||||
this.scanInString = false
|
||||
this.scanEscape = false
|
||||
this.scanString = ''
|
||||
this.scanLastString = ''
|
||||
this.scanAwaitingColon = false
|
||||
this.scanCurrentKey = undefined
|
||||
this.scanCapture = undefined
|
||||
this.processLine(line, durationOverride)
|
||||
chunk = chunk.slice(newlineIndex + newlineLength)
|
||||
}
|
||||
if (this.pending.length > MAX_PENDING_LINE_CHARS) {
|
||||
const body = this.pending.startsWith(TRUNCATION_MARKER)
|
||||
? this.pending.slice(TRUNCATION_MARKER.length)
|
||||
: this.pending
|
||||
this.pending = TRUNCATION_MARKER + body.slice(0, 256 * 1024) + body.slice(-256 * 1024)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
if (finalize && this.pending) {
|
||||
this.processLine(this.pending, this.pendingDurationMs)
|
||||
this.pending = ''
|
||||
this.pendingDurationMs = undefined
|
||||
}
|
||||
return limit > 0 ? this.state.points.slice(-limit) : this.state.points.slice()
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCodexThroughput(filePath: string, limit = 10): Promise<CodexThroughputPoint[]> {
|
||||
return new CodexThroughputReader().update(filePath, limit, true)
|
||||
}
|
||||
|
||||
export async function newestCodexSession(sessions: Array<{ path: string }>): Promise<string | undefined> {
|
||||
let newest: { path: string; mtimeMs: number } | undefined
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
const info = await stat(session.path)
|
||||
if (!newest || info.mtimeMs > newest.mtimeMs) newest = { path: session.path, mtimeMs: info.mtimeMs }
|
||||
} catch {
|
||||
// A session can disappear while Codex rotates or archives it.
|
||||
}
|
||||
}
|
||||
return newest?.path
|
||||
}
|
||||
|
||||
export function renderCodexThroughput(points: CodexThroughputPoint[], filePath: string): string {
|
||||
const latest = points.at(-1)
|
||||
if (!latest) return `No token_count checkpoints found in ${filePath}.`
|
||||
const lines = [
|
||||
'CodeBurn Codex throughput estimate',
|
||||
`Session: ${filePath}`,
|
||||
`Latest checkpoint: ${latest.timestamp}`,
|
||||
`Latest checkpoint tokens: ${latest.generatedTokens.toLocaleString()} (${latest.outputTokens.toLocaleString()} output + ${latest.reasoningTokens.toLocaleString()} reasoning)`,
|
||||
]
|
||||
if (latest.taskGeneratedTokens !== undefined) lines.push(`Completed task total: ${latest.taskGeneratedTokens.toLocaleString()} generated tokens`)
|
||||
if (latest.activeGeneratedTokensPerSecond !== undefined) {
|
||||
lines.push(`Active throughput: ${latest.activeGeneratedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.activeDurationSeconds!.toFixed(1)}s`)
|
||||
lines.push(`Excluded tool wait: ${latest.toolWaitSeconds!.toFixed(1)}s`)
|
||||
} else if (latest.generatedTokensPerSecond !== undefined) {
|
||||
lines.push(`Checkpoint estimate: ${latest.generatedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.elapsedSeconds!.toFixed(1)}s`)
|
||||
} else {
|
||||
lines.push('Throughput: unavailable (waiting for a completed turn)')
|
||||
}
|
||||
lines.push('Note: offline JSONL estimate; tool intervals are removed, but server/prompt latency may remain.')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
|
@ -46,6 +46,8 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean,
|
|||
return historyProjectCount === 0 && !historyLoading
|
||||
}
|
||||
|
||||
// The By Model panel drops the Tok/s column when the panel is too narrow, so
|
||||
// the wider two-column layout can still activate at ordinary terminal widths.
|
||||
const MIN_WIDE = 90
|
||||
const ORANGE = '#FF8C42'
|
||||
const DIM = '#555555'
|
||||
|
|
@ -196,9 +198,9 @@ function nextTick(): Promise<void> {
|
|||
return new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
|
||||
export type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
|
||||
|
||||
function getLayout(columns?: number): Layout {
|
||||
export function getLayout(columns?: number): Layout {
|
||||
const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80
|
||||
const dashWidth = Math.min(160, termWidth)
|
||||
const wide = dashWidth >= MIN_WIDE
|
||||
|
|
@ -440,6 +442,7 @@ const MODEL_COL_COST = 8
|
|||
const MODEL_COL_CACHE = 7
|
||||
const MODEL_COL_CALLS = 7
|
||||
const MODEL_COL_ONESHOT = 7
|
||||
const MODEL_COL_TPS = 7
|
||||
const MODEL_NAME_WIDTH = 14
|
||||
const MIN_EDIT_TURNS_FOR_RATE = 5
|
||||
|
||||
|
|
@ -449,6 +452,10 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
const modelTotals = aggregateModelTotals(projects)
|
||||
const modelEfficiency = aggregateModelEfficiency(projects)
|
||||
const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0)
|
||||
const anyActiveTiming = Object.values(modelTotals).some(d => d.activeDurationMs > 0 && d.activeGeneratedTokens > 0)
|
||||
// The Tok/s column needs 61 inner columns for the full row; hide it on narrower
|
||||
// panels and when no model has timing data (non-Codex users get no dead column).
|
||||
const showTps = pw - PANEL_CHROME >= 61 && anyActiveTiming
|
||||
const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
|
||||
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
|
||||
const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({
|
||||
|
|
@ -460,7 +467,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
|
||||
return (
|
||||
<Panel title="By Model" color={PANEL_COLORS.model} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}</Text>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{showTps ? 'Tok/s'.padStart(MODEL_COL_TPS) : ''}</Text>
|
||||
{sorted.map(([model, data], i) => {
|
||||
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
|
||||
const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0
|
||||
|
|
@ -469,6 +476,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null
|
||||
? `${efficiency.oneShotRate.toFixed(1)}%`
|
||||
: '-'
|
||||
const tpsLabel = data.activeDurationMs > 0 && data.activeGeneratedTokens > 0
|
||||
? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1)
|
||||
: '-'
|
||||
return (
|
||||
<Text key={`${model}-${i}`} wrap="truncate-end">
|
||||
<HBar value={data.costUSD} max={maxCost} width={bw} />
|
||||
|
|
@ -477,6 +487,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
<Text>{cacheLabel.padStart(MODEL_COL_CACHE)}</Text>
|
||||
<Text>{String(data.calls).padStart(MODEL_COL_CALLS)}</Text>
|
||||
<Text>{oneShotLabel.padStart(MODEL_COL_ONESHOT)}</Text>
|
||||
{showTps && <Text>{tpsLabel.padStart(MODEL_COL_TPS)}</Text>}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
|
@ -488,6 +499,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
{anyEstimated && (
|
||||
<Text dimColor wrap="truncate-end">~ estimated cost (priced from estimated tokens)</Text>
|
||||
)}
|
||||
{showTps && (
|
||||
<Text dimColor wrap="truncate-end">~ Tok/s: generated tokens / active time; tool wait excluded</Text>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
106
src/main.ts
106
src/main.ts
|
|
@ -5,6 +5,7 @@ import { exportCsv, exportJson, type PeriodExport } from './export.js'
|
|||
import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
|
||||
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI } from './parser.js'
|
||||
import { allProviderNames, getAllProviders } from './providers/index.js'
|
||||
import { getProvider } from './providers/index.js'
|
||||
import { convertCost, formatCost } from './currency.js'
|
||||
import { renderStatusBar } from './format.js'
|
||||
import { toDateString } from './daily-cache.js'
|
||||
|
|
@ -46,6 +47,7 @@ import { createRequire } from 'node:module'
|
|||
const require = createRequire(import.meta.url)
|
||||
const { version } = require('../package.json')
|
||||
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
|
||||
import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js'
|
||||
|
||||
// A downstream reader that closes the pipe early (`| head`, quitting `less`, or
|
||||
// a missing command) makes stdout writes fail with EPIPE. Exit cleanly rather
|
||||
|
|
@ -68,6 +70,22 @@ function parseInteger(value: string): number {
|
|||
return parseInt(value, 10)
|
||||
}
|
||||
|
||||
function parseCodexTpsLimit(value: string): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > 10000) {
|
||||
throw new Error('limit must be an integer from 1 to 10000')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseCodexTpsWatch(value: string): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || (parsed > 0 && parsed < 1) || parsed > 3600) {
|
||||
throw new Error('watch must be 0 or at least 1 second (up to 3600 seconds)')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
type PriceOverrideConfig = NonNullable<CodeburnConfig['priceOverrides']>[string]
|
||||
|
||||
type PriceOverrideOptions = {
|
||||
|
|
@ -1843,6 +1861,94 @@ program
|
|||
await runContextCommand(session, opts)
|
||||
})
|
||||
|
||||
program
|
||||
.command('codex-tps [session]')
|
||||
.description('Retrospective Codex generated-tokens/sec estimate from rollout checkpoints (not live decode speed)')
|
||||
.option('--json', 'JSON output')
|
||||
.option('--limit <n>', 'Number of recent checkpoints to scan', parseCodexTpsLimit, 10)
|
||||
.option('--watch <seconds>', 'Refresh continuously while Codex writes checkpoints', parseCodexTpsWatch, 0)
|
||||
.action(async (session: string | undefined, opts: { json?: boolean; limit: number; watch: number }) => {
|
||||
const intervalMs = Math.max(0, opts.watch) * 1000
|
||||
if (opts.json && intervalMs > 0) {
|
||||
process.stderr.write('codeburn codex-tps: --json cannot be combined with --watch; use text watch output or one-shot JSON.\n')
|
||||
process.exitCode = 2
|
||||
return
|
||||
}
|
||||
const provider = await getProvider('codex')
|
||||
if (!provider) {
|
||||
process.stderr.write('codeburn codex-tps: Codex provider is unavailable.\n')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
let cachedPath: string | undefined = session
|
||||
let throughputReader: CodexThroughputReader | undefined
|
||||
let lastFileState: { size: number; mtimeMs: number } | undefined
|
||||
let lastDiscoveryMs = 0
|
||||
let refreshInFlight = false
|
||||
const render = async (): Promise<void> => {
|
||||
if (refreshInFlight) return
|
||||
refreshInFlight = true
|
||||
try {
|
||||
let filePath = session ?? cachedPath
|
||||
// Keep an idle watcher on its chosen rollout. A full active+archive
|
||||
// discovery can be hundreds of milliseconds on large histories, so
|
||||
// only re-scan slowly to notice rotation; disappearance still triggers
|
||||
// an immediate discovery on the next tick.
|
||||
if (!session && (!filePath || Date.now() - lastDiscoveryMs >= 60_000)) {
|
||||
lastDiscoveryMs = Date.now()
|
||||
filePath = await newestCodexSession(await provider.discoverSessions())
|
||||
}
|
||||
if (!filePath) {
|
||||
process.stderr.write('codeburn codex-tps: no Codex rollout sessions found.\n')
|
||||
if (intervalMs === 0) process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const previousPath = cachedPath
|
||||
cachedPath = filePath
|
||||
if (previousPath !== filePath || !throughputReader) throughputReader = new CodexThroughputReader()
|
||||
const fileInfo = await import('node:fs/promises').then(fs => fs.stat(filePath)).catch(() => null)
|
||||
if (!fileInfo) {
|
||||
process.stderr.write(`codeburn codex-tps: session file not found: ${filePath}\n`)
|
||||
if (intervalMs === 0) process.exitCode = 1
|
||||
if (!session) cachedPath = undefined
|
||||
return
|
||||
}
|
||||
if (intervalMs > 0 && lastFileState && fileInfo.size === lastFileState.size && fileInfo.mtimeMs === lastFileState.mtimeMs) return
|
||||
lastFileState = { size: fileInfo.size, mtimeMs: fileInfo.mtimeMs }
|
||||
const points = await throughputReader!.update(filePath, opts.limit, intervalMs === 0)
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify({ session: filePath, points, live: intervalMs > 0 }, null, 2) + '\n')
|
||||
} else {
|
||||
if (intervalMs > 0) process.stdout.write('\x1b[2J\x1b[H')
|
||||
process.stdout.write(renderCodexThroughput(points, filePath) + (intervalMs > 0 ? '\nWatching for new Codex checkpoints... (Ctrl-C to stop)\n' : '\n'))
|
||||
}
|
||||
} finally {
|
||||
refreshInFlight = false
|
||||
}
|
||||
}
|
||||
try {
|
||||
await render()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`)
|
||||
if (intervalMs === 0) {
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
if (intervalMs > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
void render().catch(error => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`)
|
||||
})
|
||||
}, intervalMs)
|
||||
process.once('SIGINT', () => { clearInterval(timer); resolve() })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command('compare')
|
||||
.description('Compare two AI models side-by-side')
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export interface ModelTotals {
|
|||
freshInput: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
activeDurationMs: number
|
||||
activeGeneratedTokens: number
|
||||
}
|
||||
|
||||
/// Aggregate per-model usage across every session, keyed by the friendly display
|
||||
|
|
@ -24,6 +26,7 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record<string,
|
|||
const name = getShortModelName(model)
|
||||
const totals = (modelTotals[name] ??= {
|
||||
calls: 0, costUSD: 0, estimatedCostUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0,
|
||||
activeDurationMs: 0, activeGeneratedTokens: 0,
|
||||
})
|
||||
totals.calls += data.calls
|
||||
totals.costUSD += data.costUSD
|
||||
|
|
@ -31,6 +34,8 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record<string,
|
|||
totals.freshInput += data.tokens.inputTokens
|
||||
totals.cacheRead += data.tokens.cacheReadInputTokens
|
||||
totals.cacheWrite += data.tokens.cacheCreationInputTokens
|
||||
totals.activeDurationMs += data.activeDurationMs ?? 0
|
||||
totals.activeGeneratedTokens += data.activeGeneratedTokens ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1734,6 +1734,11 @@ function buildSessionSummary(
|
|||
modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens
|
||||
modelBreakdown[modelKey].tokens.cacheCreationInputTokens += call.usage.cacheCreationInputTokens
|
||||
modelBreakdown[modelKey].tokens.reasoningTokens += call.usage.reasoningTokens
|
||||
if (call.activeDurationMs !== undefined) {
|
||||
modelBreakdown[modelKey].activeDurationMs = (modelBreakdown[modelKey].activeDurationMs ?? 0) + call.activeDurationMs
|
||||
modelBreakdown[modelKey].activeGeneratedTokens = (modelBreakdown[modelKey].activeGeneratedTokens ?? 0) + (call.activeGeneratedTokens ?? call.usage.outputTokens + call.usage.reasoningTokens)
|
||||
modelBreakdown[modelKey].toolWaitMs = (modelBreakdown[modelKey].toolWaitMs ?? 0) + (call.toolWaitMs ?? 0)
|
||||
}
|
||||
|
||||
for (const tool of extractCoreTools(call.tools)) {
|
||||
toolBreakdown[tool] = toolBreakdown[tool] ?? { calls: 0 }
|
||||
|
|
@ -2393,6 +2398,9 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
|
|||
...(call.locAdded ? { locAdded: call.locAdded } : {}),
|
||||
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
|
||||
...(call.editFailed ? { editFailed: call.editFailed } : {}),
|
||||
activeDurationMs: call.activeDurationMs,
|
||||
activeGeneratedTokens: call.activeGeneratedTokens,
|
||||
toolWaitMs: call.toolWaitMs,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2429,6 +2437,9 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
|
|||
...(call.interrupted ? { interrupted: true } : {}),
|
||||
...(call.userModified ? { userModified: true } : {}),
|
||||
...(call.toolErrors ? { toolErrors: call.toolErrors } : {}),
|
||||
activeDurationMs: call.activeDurationMs,
|
||||
activeGeneratedTokens: call.activeGeneratedTokens,
|
||||
toolWaitMs: call.toolWaitMs,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2541,6 +2552,9 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
|
|||
deduplicationKey: call.deduplicationKey,
|
||||
cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined,
|
||||
toolSequence: call.toolSequence,
|
||||
activeDurationMs: call.activeDurationMs,
|
||||
activeGeneratedTokens: call.activeGeneratedTokens,
|
||||
toolWaitMs: call.toolWaitMs,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0
|
|||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
exec_command: 'Bash',
|
||||
// Codex Desktop's custom-tool transport uses the shorter `exec` name for
|
||||
// the same shell tool that CLI rollouts record as `exec_command`.
|
||||
exec: 'Bash',
|
||||
read_file: 'Read',
|
||||
write_file: 'Edit',
|
||||
apply_diff: 'Edit',
|
||||
|
|
@ -96,6 +99,11 @@ type CodexEntry = {
|
|||
timestamp?: string
|
||||
payload?: {
|
||||
type?: string
|
||||
turn_id?: string
|
||||
call_id?: string
|
||||
started_at?: number
|
||||
duration_ms?: number
|
||||
duration?: { secs?: number; nanos?: number } | string
|
||||
role?: string
|
||||
cwd?: string
|
||||
model_provider?: string
|
||||
|
|
@ -104,6 +112,7 @@ type CodexEntry = {
|
|||
forked_from_id?: string
|
||||
model?: string
|
||||
name?: string
|
||||
invocation?: { server?: string; tool?: string }
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
info?: {
|
||||
model?: string
|
||||
|
|
@ -196,11 +205,128 @@ function getRawJsonStringField(head: string, field: string): string | undefined
|
|||
}
|
||||
}
|
||||
|
||||
function getRawJsonNumberField(head: string, field: string): number | undefined {
|
||||
const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(head)
|
||||
if (!match) return undefined
|
||||
const value = Number(match[1])
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function getRawPayloadFieldWindow(source: Buffer, field: string, windowBytes = 4096): string | undefined {
|
||||
const payloadKey = Buffer.from('"payload"')
|
||||
const payloadIndex = source.indexOf(payloadKey)
|
||||
if (payloadIndex < 0) return undefined
|
||||
let payloadStart = source.indexOf(0x7b, payloadIndex + payloadKey.length) // {
|
||||
if (payloadStart < 0) return undefined
|
||||
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
for (let i = payloadStart; i < source.length; i++) {
|
||||
const byte = source[i]!
|
||||
if (inString) {
|
||||
if (escaped) escaped = false
|
||||
else if (byte === 0x5c) escaped = true // \\
|
||||
else if (byte === 0x22) inString = false // "
|
||||
continue
|
||||
}
|
||||
if (byte === 0x22) {
|
||||
const keyStart = i + 1
|
||||
let keyEnd = keyStart
|
||||
let keyEscaped = false
|
||||
for (; keyEnd < source.length; keyEnd++) {
|
||||
const keyByte = source[keyEnd]!
|
||||
if (keyEscaped) { keyEscaped = false; continue }
|
||||
if (keyByte === 0x5c) { keyEscaped = true; continue }
|
||||
if (keyByte === 0x22) break
|
||||
}
|
||||
if (depth === 1 && keyEnd < source.length) {
|
||||
const key = source.subarray(keyStart, keyEnd).toString('utf-8')
|
||||
let valueStart = keyEnd + 1
|
||||
while (valueStart < source.length && (source[valueStart] === 0x20 || source[valueStart] === 0x09 || source[valueStart] === 0x0a || source[valueStart] === 0x0d)) valueStart++
|
||||
if (source[valueStart] === 0x3a && key === field) {
|
||||
return source.subarray(i, Math.min(source.length, i + windowBytes)).toString('utf-8')
|
||||
}
|
||||
}
|
||||
i = keyEnd
|
||||
inString = false
|
||||
continue
|
||||
}
|
||||
if (byte === 0x22) inString = true
|
||||
else if (byte === 0x7b || byte === 0x5b) depth++ // { or [
|
||||
else if (byte === 0x7d || byte === 0x5d) depth-- // } or ]
|
||||
if (depth < 0) break
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getRawDurationMs(head: string): number | undefined {
|
||||
const objectMatch = /"duration"\s*:\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(head)
|
||||
if (objectMatch) {
|
||||
const seconds = Number(objectMatch[1])
|
||||
const nanos = Number(objectMatch[2])
|
||||
if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6
|
||||
}
|
||||
const text = getRawJsonStringField(head, 'duration')
|
||||
if (text) {
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(text.trim())
|
||||
if (match) {
|
||||
const value = Number(match[1])
|
||||
if (Number.isFinite(value)) return value * (match[2] === 's' ? 1000 : 1)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function durationValueMs(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'object' && value) {
|
||||
const record = value as Record<string, unknown>
|
||||
const seconds = record['secs']
|
||||
const nanos = record['nanos']
|
||||
if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) {
|
||||
return seconds * 1000 + nanos / 1e6
|
||||
}
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value.trim())
|
||||
if (match) {
|
||||
const parsed = Number(match[1])
|
||||
if (Number.isFinite(parsed)) return parsed * (match[2] === 's' ? 1000 : 1)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getRawTokenUsage(head: string, field: 'last_token_usage' | 'total_token_usage'): CodexTokenUsage | undefined {
|
||||
const match = new RegExp(`"${field}"\\s*:\\s*\\{([^}]*)\\}`).exec(head)
|
||||
if (!match) return undefined
|
||||
const body = match[1]!
|
||||
return {
|
||||
input_tokens: getRawJsonNumberField(body, 'input_tokens'),
|
||||
cached_input_tokens: getRawJsonNumberField(body, 'cached_input_tokens'),
|
||||
output_tokens: getRawJsonNumberField(body, 'output_tokens'),
|
||||
reasoning_output_tokens: getRawJsonNumberField(body, 'reasoning_output_tokens'),
|
||||
total_tokens: getRawJsonNumberField(body, 'total_tokens'),
|
||||
}
|
||||
}
|
||||
|
||||
function payloadHead(head: string): string {
|
||||
const idx = head.indexOf('"payload"')
|
||||
return idx === -1 ? head : head.slice(idx)
|
||||
}
|
||||
|
||||
function getRawInvocation(head: string): { server?: string; tool?: string } | undefined {
|
||||
const idx = head.indexOf('"invocation"')
|
||||
if (idx === -1) return undefined
|
||||
// Server/tool are shallow fields and precede the potentially huge arguments
|
||||
// object in Codex MCP records. Limit this scan to keep compact parsing cheap.
|
||||
const invocationHead = head.slice(idx, idx + 8192)
|
||||
const server = getRawJsonStringField(invocationHead, 'server')
|
||||
const tool = getRawJsonStringField(invocationHead, 'tool')
|
||||
return server || tool ? { server, tool } : undefined
|
||||
}
|
||||
|
||||
function countJsonStringBytes(source: Buffer, valueStart: number): number {
|
||||
let count = 0
|
||||
for (let i = valueStart; i < source.length; i++) {
|
||||
|
|
@ -270,6 +396,31 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
|
|||
const pHead = payloadHead(head)
|
||||
const payloadType = getRawJsonStringField(pHead, 'type')
|
||||
const role = getRawJsonStringField(pHead, 'role')
|
||||
// task_complete appends the potentially huge final assistant message before
|
||||
// its duration fields. Fall back to the full Buffer only for this event so
|
||||
// timing metadata is not lost when the compact head stops early.
|
||||
const needsTimingTail = type === 'event_msg' && (payloadType === 'task_complete' || payloadType === 'mcp_tool_call_end')
|
||||
const timingTail = needsTimingTail && line.length > RAW_HEAD_BYTES
|
||||
? line.subarray(Math.max(0, line.length - 16 * 1024)).toString('utf-8')
|
||||
: pHead
|
||||
const timingNumber = (field: string): number | undefined =>
|
||||
getRawJsonNumberField(pHead, field) ?? getRawJsonNumberField(timingTail, field)
|
||||
// MCP records can place a large invocation.arguments object before duration
|
||||
// and a large result after it. Searching a small window around the field
|
||||
// avoids materializing the middle of the Buffer while still preserving wait
|
||||
// timing for those records.
|
||||
const payloadDuration = payloadType === 'mcp_tool_call_end'
|
||||
? getRawDurationMs(getRawPayloadFieldWindow(line, 'duration') ?? '')
|
||||
: undefined
|
||||
const timingDuration = payloadDuration ?? getRawDurationMs(pHead) ?? getRawDurationMs(timingTail)
|
||||
const compactModel = getRawJsonStringField(pHead, 'model')
|
||||
const compactModelName = getRawJsonStringField(pHead, 'model_name')
|
||||
const compactLastUsage = getRawTokenUsage(pHead, 'last_token_usage')
|
||||
const compactTotalUsage = getRawTokenUsage(pHead, 'total_token_usage')
|
||||
const compactInfo = compactModel || compactModelName || compactLastUsage || compactTotalUsage
|
||||
? { model: compactModel, model_name: compactModelName, last_token_usage: compactLastUsage, total_token_usage: compactTotalUsage }
|
||||
: undefined
|
||||
const invocation = getRawInvocation(pHead) ?? getRawInvocation(timingTail)
|
||||
|
||||
const entry: CodexEntry = {
|
||||
type,
|
||||
|
|
@ -284,6 +435,16 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
|
|||
forked_from_id: getRawJsonStringField(pHead, 'forked_from_id'),
|
||||
model: getRawJsonStringField(pHead, 'model'),
|
||||
name: getRawJsonStringField(pHead, 'name'),
|
||||
invocation,
|
||||
call_id: getRawJsonStringField(pHead, 'call_id'),
|
||||
turn_id: getRawJsonStringField(pHead, 'turn_id'),
|
||||
// On mcp_tool_call_end a coincidental `duration_ms` inside the large
|
||||
// invocation.arguments object can shadow the payload-level duration, so the
|
||||
// depth-aware value wins. The naive scan stays as the fallback for
|
||||
// task_complete, which records duration_ms at the payload level directly.
|
||||
duration_ms: timingDuration ?? timingNumber('duration_ms'),
|
||||
started_at: timingNumber('started_at'),
|
||||
info: compactInfo,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +461,8 @@ async function discoverSessionFile(filePath: string): Promise<SessionSource | nu
|
|||
const s = await stat(filePath).catch(() => null)
|
||||
if (!s?.isFile()) return null
|
||||
|
||||
// Fast path: cached results already know the project, so avoid opening the
|
||||
// file. This keeps discovery cheap on large session directories.
|
||||
const cachedProject = await getCachedCodexProject(filePath)
|
||||
if (cachedProject) {
|
||||
return { path: filePath, project: cachedProject, provider: 'codex' }
|
||||
|
|
@ -314,6 +477,12 @@ async function discoverSessionFile(filePath: string): Promise<SessionSource | nu
|
|||
|
||||
async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
// Codex archives a session by moving it from sessions/YYYY/MM/DD/ to
|
||||
// archived_sessions/, keeping the same basename. Deduplicate by basename so
|
||||
// a session does not appear twice while it exists in both roots. This avoids
|
||||
// reading every file to extract session_id and preserves the cheap cached
|
||||
// fast path.
|
||||
const seenBasenames = new Set<string>()
|
||||
const sessionsDir = join(codexDir, 'sessions')
|
||||
|
||||
const years = await readdir(sessionsDir).catch(() => [] as string[])
|
||||
|
|
@ -335,8 +504,9 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
|
|||
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
|
||||
const filePath = join(dayDir, file)
|
||||
const source = await discoverSessionFile(filePath)
|
||||
if (seenBasenames.has(file)) continue
|
||||
seenBasenames.add(file)
|
||||
const source = await discoverSessionFile(join(dayDir, file))
|
||||
if (source) sources.push(source)
|
||||
}
|
||||
}
|
||||
|
|
@ -345,10 +515,14 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
|
|||
|
||||
// Codex moves archived sessions into a flat directory. Keep them in usage
|
||||
// reports so archiving a conversation does not erase its historical usage.
|
||||
// Call-level deduplication (seenKeys) already collapses any remaining
|
||||
// archived copies, while basename dedup above prevents double discovery.
|
||||
const archivedDir = join(codexDir, 'archived_sessions')
|
||||
const archivedFiles = await readdir(archivedDir).catch(() => [] as string[])
|
||||
for (const file of archivedFiles) {
|
||||
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
|
||||
if (seenBasenames.has(file)) continue
|
||||
seenBasenames.add(file)
|
||||
const source = await discoverSessionFile(join(archivedDir, file))
|
||||
if (source) sources.push(source)
|
||||
}
|
||||
|
|
@ -410,6 +584,16 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
let currentTurnId = `${sessionId}:t0`
|
||||
let sawAnyLine = false
|
||||
const results: ParsedProviderCall[] = []
|
||||
// Calls decoded since the last task_started, held back so task_complete can
|
||||
// stamp active/toolWait timing before they are appended to results. Emitting
|
||||
// a task only once its timing is known keeps single-pass and split/resume
|
||||
// decodes in agreement instead of back-patching already-emitted calls.
|
||||
// Bounded by one task's calls; flushed at the next task_started and at EOF.
|
||||
let pendingTaskCalls: ParsedProviderCall[] = []
|
||||
let taskGeneratedTokens = 0
|
||||
let taskToolIntervals: Array<[number, number]> = []
|
||||
let taskStartedAt: number | undefined
|
||||
const openToolStarts = new Map<string, number>()
|
||||
|
||||
// Stream the session file line by line. Heavy Codex sessions can exceed
|
||||
// 250 MB on disk; reading the entire file into a string would either hit
|
||||
|
|
@ -437,7 +621,32 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
|
||||
const isForkReplay = Boolean(forkCutoff && entry.timestamp && entry.timestamp < forkCutoff)
|
||||
if (isForkReplay && (
|
||||
entry.payload?.type === 'task_started' ||
|
||||
entry.payload?.type === 'task_complete' ||
|
||||
entry.payload?.type === 'function_call' ||
|
||||
entry.payload?.type === 'function_call_output' ||
|
||||
entry.payload?.type === 'custom_tool_call' ||
|
||||
entry.payload?.type === 'custom_tool_call_output' ||
|
||||
entry.payload?.type === 'mcp_tool_call_end' ||
|
||||
entry.payload?.type === 'patch_apply_end'
|
||||
)) continue
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') {
|
||||
// Emit the previous task. If it never reached task_complete its timing
|
||||
// fields simply stay unset, matching the un-buffered behaviour.
|
||||
results.push(...pendingTaskCalls)
|
||||
pendingTaskCalls = []
|
||||
taskGeneratedTokens = 0
|
||||
taskToolIntervals = []
|
||||
const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
taskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined
|
||||
openToolStarts.clear()
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call')) {
|
||||
const rawName = entry.payload.name ?? ''
|
||||
const mapped = toolNameMap[rawName] ?? rawName
|
||||
pendingTools.push(mapped)
|
||||
|
|
@ -459,10 +668,52 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
pendingToolSequence.push([{ tool: mcpTool }])
|
||||
}
|
||||
}
|
||||
const callId = entry.payload.call_id
|
||||
const started = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
if (callId && Number.isFinite(started)) openToolStarts.set(callId, started)
|
||||
pendingToolSequence.push([call])
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output')) {
|
||||
const callId = entry.payload.call_id
|
||||
const ended = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
const started = callId ? openToolStarts.get(callId) : undefined
|
||||
if (started !== undefined && Number.isFinite(ended) && ended > started) taskToolIntervals.push([started, ended])
|
||||
if (callId) openToolStarts.delete(callId)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') {
|
||||
const durationMs = entry.payload.duration_ms
|
||||
if (typeof durationMs === 'number' && durationMs > 0 && taskGeneratedTokens > 0 && pendingTaskCalls.length > 0) {
|
||||
const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
const windowStart = taskStartedAt ?? (Number.isFinite(completedAt) ? completedAt - durationMs : undefined)
|
||||
const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined
|
||||
const clipped = taskToolIntervals.map(([start, end]) => [
|
||||
windowStart !== undefined ? Math.max(start, windowStart) : start,
|
||||
windowEnd !== undefined ? Math.min(end, windowEnd) : end,
|
||||
] as [number, number]).filter(([start, end]) => end > start)
|
||||
const merged = clipped.sort((a, b) => a[0] - b[0]).reduce<Array<[number, number]>>((acc, interval) => {
|
||||
const previous = acc.at(-1)
|
||||
if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1])
|
||||
else acc.push([...interval])
|
||||
return acc
|
||||
}, [])
|
||||
const toolWaitMs = Math.min(durationMs, merged.reduce((sum, interval) => sum + interval[1] - interval[0], 0))
|
||||
const activeMs = durationMs - toolWaitMs
|
||||
if (activeMs <= 0) continue
|
||||
for (const call of pendingTaskCalls) {
|
||||
const generated = call.outputTokens + call.reasoningTokens
|
||||
if (generated <= 0) continue
|
||||
call.activeGeneratedTokens = generated
|
||||
call.activeDurationMs = activeMs * (generated / taskGeneratedTokens)
|
||||
call.toolWaitMs = toolWaitMs * (generated / taskGeneratedTokens)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'patch_apply_end') {
|
||||
pendingTools.push('Edit')
|
||||
const p = entry.payload as Record<string, unknown>
|
||||
|
|
@ -490,6 +741,11 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
// attributed. Rebuild the canonical `mcp__<server>__<tool>` name the
|
||||
// classifier recognizes.
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end') {
|
||||
const endedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
const durationMs = entry.payload.duration_ms ?? durationValueMs(entry.payload.duration)
|
||||
if (typeof durationMs === 'number' && durationMs > 0 && Number.isFinite(endedAt)) {
|
||||
taskToolIntervals.push([endedAt - durationMs, endedAt])
|
||||
}
|
||||
const inv = (entry.payload as Record<string, unknown>)['invocation'] as Record<string, unknown> | undefined
|
||||
const server = typeof inv?.['server'] === 'string' ? inv['server'] as string : ''
|
||||
const tool = typeof inv?.['tool'] === 'string' ? inv['tool'] as string : ''
|
||||
|
|
@ -542,7 +798,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
|
||||
const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0)
|
||||
|
||||
results.push({
|
||||
pendingTaskCalls.push({
|
||||
provider: 'codex',
|
||||
model,
|
||||
inputTokens: estInput,
|
||||
|
|
@ -568,6 +824,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
|
||||
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
|
||||
})
|
||||
taskGeneratedTokens += estOutput
|
||||
|
||||
pendingTools = []
|
||||
pendingToolSequence = []
|
||||
|
|
@ -660,7 +917,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
0,
|
||||
)
|
||||
|
||||
results.push({
|
||||
pendingTaskCalls.push({
|
||||
provider: 'codex',
|
||||
model,
|
||||
inputTokens: uncachedInputTokens,
|
||||
|
|
@ -685,6 +942,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
|
||||
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
|
||||
})
|
||||
taskGeneratedTokens += outputTokens + reasoningTokens
|
||||
|
||||
pendingTools = []
|
||||
pendingToolSequence = []
|
||||
|
|
@ -701,6 +959,9 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
// result set against a fingerprint that would otherwise be re-parsed.
|
||||
if (!sawAnyLine) return
|
||||
|
||||
// Flush the final task, which has no following task_started to trigger it.
|
||||
results.push(...pendingTaskCalls)
|
||||
|
||||
await writeCachedCodexResults(source.path, source.project, results, fp)
|
||||
|
||||
for (const call of results) {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ export type ParsedProviderCall = {
|
|||
// Exact provider-recorded cwd, kept separately because projectPath may later
|
||||
// canonicalize a linked worktree to its main repository.
|
||||
workingDirectory?: string
|
||||
activeDurationMs?: number
|
||||
activeGeneratedTokens?: number
|
||||
toolWaitMs?: number
|
||||
}
|
||||
|
||||
// A directory or database file that a provider's discoverSessions() scans.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ export type CachedCall = {
|
|||
toolErrors?: number
|
||||
// Codex: count of this call's patch applications with success === false.
|
||||
editFailed?: number
|
||||
activeDurationMs?: number
|
||||
activeGeneratedTokens?: number
|
||||
toolWaitMs?: number
|
||||
}
|
||||
|
||||
export type CachedTurn = {
|
||||
|
|
@ -213,7 +216,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// 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-cross-provider-pr-v1',
|
||||
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1',
|
||||
cursor: 'composer-anchored-crediting-v1-est-cost',
|
||||
'cursor-agent': 'workspaceless-transcript-v1',
|
||||
copilot: 'cli-shutdown-cost-v1-skills',
|
||||
|
|
@ -337,6 +340,9 @@ function validateCall(c: unknown): c is CachedCall {
|
|||
&& (o['speed'] === 'standard' || o['speed'] === 'fast')
|
||||
&& isOptionalNum(o['costUSD'])
|
||||
&& isOptionalBool(o['isEstimated'])
|
||||
&& isOptionalNum(o['activeDurationMs'])
|
||||
&& isOptionalNum(o['activeGeneratedTokens'])
|
||||
&& isOptionalNum(o['toolWaitMs'])
|
||||
&& isStringArray(o['tools'])
|
||||
&& isStringArray(o['bashCommands'])
|
||||
&& isStringArray(o['skills'])
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ export type ParsedApiCall = {
|
|||
/// 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
|
||||
activeDurationMs?: number
|
||||
activeGeneratedTokens?: number
|
||||
toolWaitMs?: number
|
||||
}
|
||||
|
||||
export type ToolCall = {
|
||||
|
|
@ -268,7 +271,7 @@ export type SessionSummary = {
|
|||
/// from a provider that never captures branches (→ contributes nothing).
|
||||
/// Claude only; absent otherwise.
|
||||
everHadBranch?: boolean
|
||||
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number }>
|
||||
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number; activeDurationMs?: number; activeGeneratedTokens?: number; toolWaitMs?: number }>
|
||||
toolBreakdown: Record<string, { calls: number }>
|
||||
mcpBreakdown: Record<string, { calls: number }>
|
||||
bashBreakdown: Record<string, { calls: number }>
|
||||
|
|
|
|||
46
tests/cli-codex-tps.test.ts
Normal file
46
tests/cli-codex-tps.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const homes: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
while (homes.length) await rm(homes.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function runCli(args: string[], home: string) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, HOME: home, CODEX_HOME: join(home, '.codex'), TZ: 'UTC' },
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
describe('codex-tps CLI validation', () => {
|
||||
it('rejects sub-second watch intervals', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
|
||||
homes.push(home)
|
||||
const result = runCli(['codex-tps', '--watch', '0.1'], home)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('watch must be 0 or at least 1 second')
|
||||
})
|
||||
|
||||
it('rejects JSON watch output instead of concatenating invalid JSON documents', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
|
||||
homes.push(home)
|
||||
const result = runCli(['codex-tps', '--json', '--watch', '1'], home)
|
||||
expect(result.status).toBe(2)
|
||||
expect(result.stderr).toContain('--json cannot be combined with --watch')
|
||||
})
|
||||
|
||||
it('returns a nonzero status for a missing explicit rollout', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
|
||||
homes.push(home)
|
||||
const result = runCli(['codex-tps', join(home, 'missing.jsonl')], home)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('session file not found')
|
||||
})
|
||||
})
|
||||
124
tests/codex-throughput.test.ts
Normal file
124
tests/codex-throughput.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { appendFile, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CodexThroughputReader, readCodexThroughput, renderCodexThroughput } from '../src/codex-throughput.js'
|
||||
|
||||
describe('Codex throughput prototype', () => {
|
||||
it('estimates generated tokens/sec between token_count checkpoints', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
await writeFile(path, [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:02.000Z', payload: { type: 'function_call', call_id: 'tool-1' } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'function_call_output', call_id: 'tool-1' } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'mcp_tool_call_end', duration: { secs: 3, nanos: 0 } } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 80, reasoning_output_tokens: 20 }, total_token_usage: { total_tokens: 100, output_tokens: 80, reasoning_output_tokens: 20 } } } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:15.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 40, reasoning_output_tokens: 10 }, total_token_usage: { total_tokens: 150, output_tokens: 120, reasoning_output_tokens: 30 } } } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:16.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
|
||||
].join('\n'))
|
||||
|
||||
const points = await readCodexThroughput(path)
|
||||
expect(points).toHaveLength(2)
|
||||
expect(points[1]).toMatchObject({ generatedTokens: 50, elapsedSeconds: 5, generatedTokensPerSecond: 10, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 21.428571428571427, toolWaitSeconds: 3, model: 'gpt-5.6-sol' })
|
||||
expect(renderCodexThroughput(points, path)).toContain('21.4 generated tokens/sec')
|
||||
})
|
||||
|
||||
it('parses only appended complete lines while watching a growing rollout', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-watch-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
const first = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 8, reasoning_output_tokens: 2 }, total_token_usage: { total_tokens: 10, output_tokens: 8, reasoning_output_tokens: 2 } } } })
|
||||
const second = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:01.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 4, reasoning_output_tokens: 1 }, total_token_usage: { total_tokens: 15, output_tokens: 12, reasoning_output_tokens: 3 } } } })
|
||||
await writeFile(path, first.slice(0, 40))
|
||||
const reader = new CodexThroughputReader()
|
||||
expect(await reader.update(path)).toEqual([])
|
||||
await appendFile(path, first.slice(40) + '\n' + second + '\n')
|
||||
const points = await reader.update(path)
|
||||
expect(points).toHaveLength(2)
|
||||
expect(points[1]).toMatchObject({ generatedTokens: 5, generatedTokensPerSecond: 5 })
|
||||
})
|
||||
|
||||
it('ignores replayed pre-fork checkpoints before estimating new work', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-fork-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
const line = (timestamp: string, payload: Record<string, unknown>) => JSON.stringify({ type: 'event_msg', timestamp, payload })
|
||||
await writeFile(path, [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol', forked_from_id: 'parent' } }),
|
||||
line('2026-07-25T00:00:01.000Z', { type: 'task_started' }),
|
||||
line('2026-07-25T00:00:02.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } }),
|
||||
line('2026-07-25T00:00:03.000Z', { type: 'task_complete', duration_ms: 1000 }),
|
||||
line('2026-07-25T00:00:06.000Z', { type: 'task_started' }),
|
||||
line('2026-07-25T00:00:08.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 20 }, total_token_usage: { total_tokens: 20, output_tokens: 20 } } }),
|
||||
line('2026-07-25T00:00:10.000Z', { type: 'task_complete', duration_ms: 4000 }),
|
||||
].join('\n'))
|
||||
|
||||
const points = await readCodexThroughput(path)
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0]).toMatchObject({ generatedTokens: 20, activeGeneratedTokensPerSecond: 5 })
|
||||
})
|
||||
|
||||
it('keeps oversized rollout lines bounded while extracting token usage', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-large-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
const largeResult = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-07-25T00:00:01.000Z',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: { last_token_usage: { output_tokens: 12 }, total_token_usage: { total_tokens: 12, output_tokens: 12 } },
|
||||
result: 'x'.repeat(5 * 1024 * 1024),
|
||||
},
|
||||
})
|
||||
await writeFile(path, largeResult)
|
||||
const points = await readCodexThroughput(path)
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0]?.generatedTokens).toBe(12)
|
||||
})
|
||||
|
||||
it('keeps MCP duration when arguments and result surround the middle field', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-large-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
const mcp = JSON.stringify({
|
||||
type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } },
|
||||
duration: { secs: 3, nanos: 0 },
|
||||
result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) },
|
||||
},
|
||||
})
|
||||
await writeFile(path, [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
|
||||
mcp,
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
|
||||
].join('\n'))
|
||||
const points = await readCodexThroughput(path)
|
||||
expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 })
|
||||
})
|
||||
|
||||
it('keeps a streamed string MCP duration when arguments and result surround the middle field', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-string-large-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
const mcp = JSON.stringify({
|
||||
type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } },
|
||||
duration: '3s',
|
||||
result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) },
|
||||
},
|
||||
})
|
||||
await writeFile(path, [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
|
||||
mcp,
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
|
||||
].join('\n'))
|
||||
const points = await readCodexThroughput(path)
|
||||
expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 })
|
||||
})
|
||||
})
|
||||
|
|
@ -2,7 +2,7 @@ import { homedir } from 'os'
|
|||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { getDailyActivityRows, getDashboardScanRange, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
|
||||
import { getDailyActivityRows, getDashboardScanRange, getLayout, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
|
||||
import { getDateRange } from '../src/cli-date.js'
|
||||
import { formatCost } from '../src/format.js'
|
||||
import type { ProjectSummary, SessionSummary } from '../src/types.js'
|
||||
|
|
@ -248,3 +248,25 @@ describe('showEmptyState', () => {
|
|||
expect(showEmptyState(2, false, 0, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLayout - dashboard width breakpoints', () => {
|
||||
it('uses a single column at 89 columns or below', () => {
|
||||
expect(getLayout(89)).toMatchObject({ dashWidth: 89, wide: false, halfWidth: 89 })
|
||||
})
|
||||
|
||||
it('switches to two columns at 90 columns', () => {
|
||||
expect(getLayout(90)).toMatchObject({ dashWidth: 90, wide: true, halfWidth: 45 })
|
||||
})
|
||||
|
||||
it('keeps two columns at 120 columns but the By-Model panel is too narrow for Tok/s', () => {
|
||||
// Inner panel width is halfWidth - PANEL_CHROME (4). At 120 cols halfWidth=60,
|
||||
// inner=56, below the 61-col threshold where Tok/s renders.
|
||||
expect(getLayout(120)).toMatchObject({ dashWidth: 120, wide: true, halfWidth: 60 })
|
||||
expect(getLayout(120).halfWidth - 4).toBeLessThan(61)
|
||||
})
|
||||
|
||||
it('keeps two columns and has enough room for Tok/s at 130 columns', () => {
|
||||
expect(getLayout(130)).toMatchObject({ dashWidth: 130, wide: true, halfWidth: 65 })
|
||||
expect(getLayout(130).halfWidth - 4).toBeGreaterThanOrEqual(61)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -160,6 +160,56 @@ describe('codex provider - session discovery', () => {
|
|||
}])
|
||||
})
|
||||
|
||||
it('deduplicates the same session_id across active and archived roots', async () => {
|
||||
const sharedLines = [
|
||||
sessionMeta({ cwd: '/Users/test/shared', session_id: 'sess-shared' }),
|
||||
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
|
||||
]
|
||||
const activePath = await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines)
|
||||
const archivedCopyPath = await writeArchivedSession(tmpDir, 'rollout-shared.jsonl', sharedLines)
|
||||
const distinctPath = await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [
|
||||
sessionMeta({ cwd: '/Users/test/distinct', session_id: 'sess-distinct' }),
|
||||
tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
const paths = sessions.map(session => session.path)
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(paths).toEqual(expect.arrayContaining([activePath, distinctPath]))
|
||||
expect(paths).not.toContain(archivedCopyPath)
|
||||
})
|
||||
|
||||
it('does not double-count usage for an archived copy while counting distinct sessions', async () => {
|
||||
const sharedLines = [
|
||||
sessionMeta({ session_id: 'sess-shared' }),
|
||||
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
|
||||
]
|
||||
await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines)
|
||||
await writeArchivedSession(tmpDir, 'rollout-shared-copy.jsonl', sharedLines)
|
||||
await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-distinct' }),
|
||||
tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
const seenKeys = new Set<string>()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const session of sessions) {
|
||||
for await (const call of provider.createSessionParser(session, seenKeys).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
}
|
||||
|
||||
expect(calls.map(call => call.sessionId).sort()).toEqual(['sess-distinct', 'sess-shared'])
|
||||
expect(calls.reduce(
|
||||
(total, call) => total + call.inputTokens + call.cachedInputTokens + call.outputTokens + call.reasoningTokens,
|
||||
0,
|
||||
)).toBe(400)
|
||||
})
|
||||
|
||||
it('returns empty for non-existent directory', async () => {
|
||||
const provider = createCodexProvider('/nonexistent/path/that/does/not/exist')
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
|
@ -332,6 +382,89 @@ describe('codex provider - JSONL parsing', () => {
|
|||
expect(call.deduplicationKey).toContain('codex:')
|
||||
})
|
||||
|
||||
it('parses large rollout lines and computes active timing for custom tool calls', async () => {
|
||||
const largeTokenLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:01:10Z',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
last_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 },
|
||||
total_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 },
|
||||
},
|
||||
rate_limits: { filler: 'x'.repeat(40_000) },
|
||||
},
|
||||
})
|
||||
const largeCompleteLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:01:11Z',
|
||||
payload: { type: 'task_complete', last_agent_message: 'x'.repeat(40_000), duration_ms: 10_000 },
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-timing.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-timing', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started', turn_id: 'turn-1' } }),
|
||||
userMessage('run the tool'),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:02Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:05Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }),
|
||||
largeTokenLine,
|
||||
largeCompleteLine,
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
outputTokens: 100,
|
||||
reasoningTokens: 20,
|
||||
tools: ['Bash'],
|
||||
activeDurationMs: 7000,
|
||||
activeGeneratedTokens: 120,
|
||||
toolWaitMs: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps estimated output parsing for large token lines without usage info', async () => {
|
||||
// Some rollout variants put token_count metadata beyond the compact head
|
||||
// or omit `info` entirely. The line must still reach the character-based
|
||||
// estimate path rather than being interpreted as an empty usage object.
|
||||
const largeTokenLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:01:10Z',
|
||||
payload: { type: 'token_count' },
|
||||
filler: 'x'.repeat(40_000),
|
||||
})
|
||||
const assistantLine = JSON.stringify({
|
||||
type: 'response_item',
|
||||
timestamp: '2026-04-14T10:01:05Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'generated response '.repeat(100) }],
|
||||
},
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-estimated-large.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-estimated-large', model: 'gpt-5.5' }),
|
||||
userMessage('summarize the result'),
|
||||
assistantLine,
|
||||
largeTokenLine,
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
model: 'gpt-5.5',
|
||||
costIsEstimated: true,
|
||||
})
|
||||
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('attributes MCP calls emitted as event_msg/mcp_tool_call_end', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcp', model: 'gpt-5.5' }),
|
||||
|
|
@ -356,6 +489,154 @@ describe('codex provider - JSONL parsing', () => {
|
|||
expect(calls[0]!.tools).toEqual(['mcp__github__get_issue'])
|
||||
})
|
||||
|
||||
it('subtracts native MCP wait time from active timing', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-timing.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcp-timing', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('look up the issue'),
|
||||
JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:00:05Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
call_id: 'mcp-1',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: {} },
|
||||
duration: { secs: 3, nanos: 0 },
|
||||
},
|
||||
}),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:00:08Z',
|
||||
last: { input: 300, output: 100 },
|
||||
total: { total: 400 },
|
||||
}),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({ activeDurationMs: 7000, toolWaitMs: 3000 })
|
||||
})
|
||||
|
||||
it('keeps MCP attribution on large result lines', async () => {
|
||||
const largeMcpLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:00:05Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
call_id: 'mcp-large',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: { duration: '1s', body: 'x'.repeat(100_000) } },
|
||||
duration: { secs: 3, nanos: 0 },
|
||||
result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } },
|
||||
},
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-large.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcp-large', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('look up the issue'),
|
||||
largeMcpLine,
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
|
||||
})
|
||||
|
||||
it('prefers payload-level duration over a nested duration_ms in large mcp_tool_call_end lines', async () => {
|
||||
// Regression guard: a naive first-match regex would pick up the
|
||||
// `duration_ms: 9999` inside invocation.arguments instead of the payload-level
|
||||
// `duration: { secs: 3 }`. The depth-aware payload scan must win.
|
||||
const largeMcpLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:00:05Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
call_id: 'mcp-duration-collision',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: { duration_ms: 9999, body: 'x'.repeat(40_000) } },
|
||||
duration: { secs: 3, nanos: 0 },
|
||||
result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } },
|
||||
},
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-duration-collision.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcp-duration-collision', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('look up the issue'),
|
||||
largeMcpLine,
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
|
||||
})
|
||||
|
||||
it('attributes a task_complete over everything since the last task_started, even across a suppressed one', async () => {
|
||||
// A mid-file session_meta carrying forked_from_id re-arms the fork-replay
|
||||
// cutoff, which swallows the task_started right behind it while its
|
||||
// task_complete lands past the cutoff. Attribution then has to span both
|
||||
// turns, exactly as it did before calls were buffered per task.
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-suppressed-task-start.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-suppressed-start', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('first ask'),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:05Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
sessionMeta({ timestamp: '2026-04-14T10:00:11Z', session_id: 'sess-suppressed-start', model: 'gpt-5.5', forked_from_id: 'sess-parent' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:12Z', payload: { type: 'task_started' } }),
|
||||
userMessage('second ask', '2026-04-14T10:00:18Z'),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:20Z', last: { input: 300, output: 300 }, total: { total: 1000 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:25Z', payload: { type: 'task_complete', duration_ms: 5_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
// The second task_complete re-attributes the first turn too, so the 5s
|
||||
// window is split across both by generated tokens rather than leaving the
|
||||
// first turn pinned to its own 10s window.
|
||||
expect(calls[0]!.activeDurationMs).toBeCloseTo(1250, 6)
|
||||
expect(calls[1]!.activeDurationMs).toBeCloseTo(3750, 6)
|
||||
expect(calls[0]!.activeDurationMs! + calls[1]!.activeDurationMs!).toBeCloseTo(5000, 6)
|
||||
})
|
||||
|
||||
it('omits active timing when recorded tool wait consumes the task duration', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-degenerate-timing.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-degenerate-timing', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('wait for the tool'),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:13Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.activeDurationMs).toBeUndefined()
|
||||
expect(calls[0]!.toolWaitMs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('attributes CLI-wrapped MCP calls (mcp-cli call server tool) to MCP + Bash', async () => {
|
||||
const execStr = (command: string) => JSON.stringify({
|
||||
type: 'response_item',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue