fix(parser): range-filter calls inside turns instead of dropping day-spanning turns

parseProviderSources keyed the dateRange check on a turn's first call
timestamp, so a long autonomous turn starting before midnight was
excluded from the next day's view entirely and every post-midnight
call in it was lost. Filter calls inside the turn instead and keep
the turn when any call falls in range.

Fixes #852
This commit is contained in:
KENSHI601 2026-07-29 02:08:10 +08:00
parent 85781999a5
commit 8b83ded657
2 changed files with 59 additions and 10 deletions

View file

@ -2987,17 +2987,22 @@ async function parseProviderSources(
const cachedFile = section.files[source.path]
if (!cachedFile) continue
for (const turn of cachedFile.turns) {
for (let turn of cachedFile.turns) {
const hasDup = turn.calls.some(c => seenKeys.has(c.deduplicationKey))
if (hasDup) continue
for (const c of turn.calls) seenKeys.add(c.deduplicationKey)
if (dateRange) {
const callTs = turn.calls[0]?.timestamp
if (!callTs) continue
const ts = new Date(callTs)
if (ts < dateRange.start || ts > dateRange.end) continue
// Filter per call, not by the turn's first call: a long autonomous turn
// can span midnight, so dropping the whole turn on its first timestamp
// loses every in-range call made after it.
const inRangeCalls = turn.calls.filter(c => {
const ts = new Date(c.timestamp).getTime()
return !isNaN(ts) && ts >= dateRange.start.getTime() && ts <= dateRange.end.getTime()
})
if (inRangeCalls.length === 0) continue
turn = { ...turn, calls: inRangeCalls }
}
const classified = cachedTurnToClassified(turn)
@ -3036,17 +3041,22 @@ async function parseProviderSources(
for (const [cachedPath, cachedFile] of Object.entries(section.files)) {
if (allDiscoveredFiles.has(cachedPath)) continue // already counted above
for (const turn of cachedFile.turns) {
for (let turn of cachedFile.turns) {
const hasDup = turn.calls.some(c => seenKeys.has(c.deduplicationKey))
if (hasDup) continue
for (const c of turn.calls) seenKeys.add(c.deduplicationKey)
if (dateRange) {
const callTs = turn.calls[0]?.timestamp
if (!callTs) continue
const ts = new Date(callTs)
if (ts < dateRange.start || ts > dateRange.end) continue
// Filter per call, not by the turn's first call: a long autonomous turn
// can span midnight, so dropping the whole turn on its first timestamp
// loses every in-range call made after it.
const inRangeCalls = turn.calls.filter(c => {
const ts = new Date(c.timestamp).getTime()
return !isNaN(ts) && ts >= dateRange.start.getTime() && ts <= dateRange.end.getTime()
})
if (inRangeCalls.length === 0) continue
turn = { ...turn, calls: inRangeCalls }
}
const classified = cachedTurnToClassified(turn)

View file

@ -197,3 +197,42 @@ describe('provider turn grouping', () => {
}
})
})
describe('provider turn range filtering', () => {
it('keeps the in-range calls of a codex turn that spans midnight instead of dropping the whole turn', async () => {
// Regression test for #852: the range filter keyed on the turn's FIRST
// call timestamp, so a long autonomous turn starting 23:59 the previous
// day was excluded from the next day's view entirely, losing every
// post-midnight call. One turn (t1) here has two token_count events
// straddling midnight; only the post-midnight call may survive.
const codexHome = join(home, 'codex')
const sessionDir = join(codexHome, 'sessions', '2026', '05', '15')
await mkdir(sessionDir, { recursive: true })
const lines = [
JSON.stringify({ type: 'session_meta', timestamp: '2026-05-15T23:55:00Z', payload: { session_id: 'sess-span', model: 'gpt-5.5', cwd: '/Users/test/project-a', originator: 'codex_cli_rs' } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-05-15T23:57:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'run the long task' }] } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-05-15T23:58:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm test' }) } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-05-15T23:59:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 30 }, total_token_usage: { total_tokens: 130 } } } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-05-16T00:10:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm run build' }) } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-05-16T00:15:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 80, output_tokens: 20 }, total_token_usage: { total_tokens: 230 } } } }),
]
await writeFile(join(sessionDir, 'rollout-span.jsonl'), lines.join('\n') + '\n')
process.env['CODEX_HOME'] = codexHome
try {
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'codex')
const session = projects[0]!.sessions[0]!
const turn = session.turns[0]!
expect(session.turns).toHaveLength(1)
expect(turn.assistantCalls.map(call => new Date(call.timestamp).toISOString())).toEqual([
'2026-05-16T00:15:00.000Z',
])
expect(session.totalInputTokens).toBe(80)
expect(session.totalOutputTokens).toBe(20)
} finally {
delete process.env['CODEX_HOME']
}
})
})