From 8b83ded657386e5270797230fd6aeab6b038df2b Mon Sep 17 00:00:00 2001 From: KENSHI601 <251418004+KENSHI601@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:08:10 +0800 Subject: [PATCH] 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 --- src/parser.ts | 30 ++++++++++++++------- tests/provider-turn-grouping.test.ts | 39 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index ee165e3..113b1fd 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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) diff --git a/tests/provider-turn-grouping.test.ts b/tests/provider-turn-grouping.test.ts index bee7585..efdf830 100644 --- a/tests/provider-turn-grouping.test.ts +++ b/tests/provider-turn-grouping.test.ts @@ -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'] + } + }) +})