From a3a4c97ec51beeccf84565456c95b93ccc87e9dd Mon Sep 17 00:00:00 2001 From: reviewer Date: Tue, 21 Jul 2026 01:08:52 +0200 Subject: [PATCH] sessions: harden PR attribution and add models + category breakdown Addresses the review findings on the per-turn PR attribution and adds the model and task-category surfaces. Correctness: - Cache migration: the 5 -> 6 session-cache bump now adopts the prior v5 file's expired-source PR entries instead of abandoning them, and the claude scan preserves and surfaces PR-bearing orphans, so a session whose transcript was deleted still appears as a legacy even-split instead of vanishing. The daily cache is untouched. - Date-range carry: the parser captures the PR set active at the start of the in-range turn slice and seeds the state machine with it, so a PR referenced before the window still owns its later, in-range, ref-less turns instead of the session falling back to a whole-session approx split. - Calls are split across a multi-PR turn by largest-remainder, keeping per-PR counts whole (a 1-call, 2-PR turn no longer renders as 2 calls). - The CLI and app footers reconcile to the rounded row values actually shown. - Distinct sessions are keyed by project + sessionId, not sessionId alone. - The app tolerates an older by-reference payload (no attributedCost): it keeps the old non-summable footer and never renders NaN. Features: - Each PR contribution records the models of its calls and the turn's task category (split by the same share on a multi-PR turn); legacy even-split rows carry the model union but no category breakdown. - Payload rows gain models (short names, cost-desc) and categories (label + cost, cost-desc, omitted when empty); the payload gains otherPrCount/otherPrCost for the PRs beyond the sent top 20. - CLI --by-pr gains a Models column. The desktop table is now full-width with a Models column and click-to-expand rows showing a per-category cost breakdown with proportional bars, keyboard accessible, with an "Other (N more PRs)" row when capped. Tests: state-machine seed/models/categories/largest-remainder/dedup, the prRefs round-trip through the real incremental-append path (continuation + straddle), v5 adoption of an expired PR session, payload round-trip, and the desktop expansion/models/old-payload cases. --- app/renderer/lib/types.ts | 14 +- app/renderer/sections/PullRequests.test.tsx | 121 +++++++++++++++- app/renderer/sections/PullRequests.tsx | 144 ++++++++++++++++--- app/renderer/styles/plain.css | 19 +++ src/main.ts | 11 +- src/menubar-json.ts | 8 +- src/parser.ts | 47 ++++-- src/session-cache.ts | 68 ++++++++- src/sessions-report.ts | 149 +++++++++++++++----- src/types.ts | 6 + src/usage-aggregator.ts | 6 +- tests/menubar-json.test.ts | 23 +++ tests/parser-incremental-append.test.ts | 57 ++++++++ tests/session-cache-v5-adoption.test.ts | 96 +++++++++++++ tests/sessions-by-pr.test.ts | 106 +++++++++++++- 15 files changed, 788 insertions(+), 87 deletions(-) create mode 100644 tests/session-cache-v5-adoption.test.ts diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index aade559..0a59d68 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -224,7 +224,11 @@ export type MenubarPayload = { // granularity. Optional: older CLIs omit it, and it is absent when no PR links // were observed. Rows carry attributed cost/calls and ARE summable; // `attributedCost + unattributedCost === distinctCost`. `approx` marks a row - // fed by the legacy whole-session even split (transcript expired). + // fed by the legacy whole-session even split (transcript expired). `models` is + // the short model names that processed the PR (cost-desc); `categories` is the + // per-task-category attributed cost (cost-desc), omitted for legacy rows. + // `attributedCost`/`unattributedCost` are optional so a payload from an older + // CLI (by-reference rows, not summable) still type-checks and can be detected. pullRequests?: { rows: Array<{ url: string @@ -236,11 +240,15 @@ export type MenubarPayload = { firstStarted: string lastEnded: string approx?: boolean + models?: string[] + categories?: Array<{ name: string; cost: number }> }> distinctCost: number distinctSessions: number - attributedCost: number - unattributedCost: number + attributedCost?: number + unattributedCost?: number + otherPrCount?: number + otherPrCost?: number } } optimize: { diff --git a/app/renderer/sections/PullRequests.test.tsx b/app/renderer/sections/PullRequests.test.tsx index e19c7ec..90b2911 100644 --- a/app/renderer/sections/PullRequests.test.tsx +++ b/app/renderer/sections/PullRequests.test.tsx @@ -46,13 +46,22 @@ function makePayload(pullRequests?: PrPayload): MenubarPayload { const SAMPLE: PrPayload = { rows: [ - { url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 240.5, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z' }, - { url: 'https://github.com/getagentseal/codeburn/pull/781', label: 'getagentseal/codeburn#781', cost: 90.25, savingsUSD: 0, sessions: 1, calls: 120, firstStarted: '2026-07-05T13:00:00Z', lastEnded: '2026-07-05T15:00:00Z' }, + { url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 240.5, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z', models: ['fable', 'opus', 'haiku'], categories: [{ name: 'Feature work', cost: 180.25 }, { name: 'Debugging', cost: 60.25 }] }, + { url: 'https://github.com/getagentseal/codeburn/pull/781', label: 'getagentseal/codeburn#781', cost: 90.25, savingsUSD: 0, sessions: 1, calls: 120, firstStarted: '2026-07-05T13:00:00Z', lastEnded: '2026-07-05T15:00:00Z', models: ['sonnet'], categories: [{ name: 'Refactoring', cost: 90.25 }] }, ], distinctCost: 376.05, distinctSessions: 3, attributedCost: 330.75, unattributedCost: 45.3, + otherPrCount: 0, + otherPrCost: 0, +} + +// Get the button-role row wrapping a given PR link, for click/keyboard toggling. +function rowForLink(link: HTMLElement): HTMLElement { + const row = link.closest('[role="button"]') + if (!row) throw new Error('expected a button-role row around the PR link') + return row as HTMLElement } describe('PullRequests', () => { @@ -75,13 +84,61 @@ describe('PullRequests', () => { expect(screen.getByText(expectedSpan(SAMPLE.rows[1]!.firstStarted, SAMPLE.rows[1]!.lastEnded))).toBeInTheDocument() }) - it('opens the PR URL externally instead of navigating', async () => { + it('renders the Models column with a "+N" overflow tag', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + // Three models collapse to the first two plus a count of the rest. + expect(await screen.findByText('fable, opus +1')).toBeInTheDocument() + // A single model renders as-is, with no overflow tag. + expect(screen.getByText('sonnet')).toBeInTheDocument() + }) + + it('opens the PR URL externally without navigating or toggling the row', async () => { getOverview.mockResolvedValue(makePayload(SAMPLE)) render() const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) await userEvent.click(link) expect(openExternal).toHaveBeenCalledWith('https://github.com/getagentseal/codeburn/pull/780') + // Clicking the link must not expand its row. + expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Feature work')).toBeNull() + }) + + it('expands a row to its category breakdown on click, then collapses', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + const row = rowForLink(link) + expect(row).toHaveAttribute('aria-expanded', 'false') + + await userEvent.click(row) + expect(row).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Feature work')).toBeInTheDocument() + expect(screen.getByText('$180.25')).toBeInTheDocument() + expect(screen.getByText('Debugging')).toBeInTheDocument() + + await userEvent.click(row) + expect(row).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Feature work')).toBeNull() + }) + + it('toggles expansion from the keyboard with Enter', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + const row = rowForLink(link) + row.focus() + + await userEvent.keyboard('{Enter}') + expect(row).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Feature work')).toBeInTheDocument() + + await userEvent.keyboard('{Enter}') + expect(row).toHaveAttribute('aria-expanded', 'false') }) it('states the attributed-total footer and the summable framing', async () => { @@ -114,6 +171,64 @@ describe('PullRequests', () => { expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull() }) + it('expands a category-less (legacy) row to a muted note, not an empty box', async () => { + const approxPayload: PrPayload = { + rows: [ + { url: 'https://github.com/getagentseal/codeburn/pull/900', label: 'getagentseal/codeburn#900', cost: 12.5, savingsUSD: 0, sessions: 1, calls: 30, firstStarted: '2026-07-10T10:00:00Z', lastEnded: '2026-07-10T11:00:00Z', approx: true }, + ], + distinctCost: 12.5, + distinctSessions: 1, + attributedCost: 12.5, + unattributedCost: 0, + } + getOverview.mockResolvedValue(makePayload(approxPayload)) + render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#900' }) + await userEvent.click(rowForLink(link)) + expect(screen.getByText(/No per-turn detail/)).toBeInTheDocument() + }) + + it('renders the old-CLI by-reference footer without NaN and never claims summable', async () => { + const oldPayload: PrPayload = { + rows: [ + { url: 'https://github.com/getagentseal/codeburn/pull/500', label: 'getagentseal/codeburn#500', cost: 120.4, savingsUSD: 0, sessions: 2, calls: 300, firstStarted: '2026-06-01T10:00:00Z', lastEnded: '2026-06-02T12:00:00Z' }, + ], + distinctCost: 120.4, + distinctSessions: 2, + } + getOverview.mockResolvedValue(makePayload(oldPayload)) + render() + + const note = await screen.findByText(/produced pull requests/) + expect(note.textContent).toContain('$120.40') + expect(note.textContent).toContain('by reference') + expect(note.textContent).toContain('not summed') + expect(note.textContent).not.toContain('summable') + // No optional field renders as NaN and no unattributed line appears. + expect(screen.queryByText(/NaN/)).toBeNull() + expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull() + }) + + it('renders an Other (N more PRs) reconciliation row when PRs are capped', async () => { + const cappedPayload: PrPayload = { + rows: [ + { url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 200, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z', models: ['fable'], categories: [{ name: 'Feature work', cost: 200 }] }, + ], + distinctCost: 288.4, + distinctSessions: 4, + attributedCost: 288.4, + unattributedCost: 0, + otherPrCount: 5, + otherPrCost: 88.4, + } + getOverview.mockResolvedValue(makePayload(cappedPayload)) + render() + + expect(await screen.findByText('Other (5 more PRs)')).toBeInTheDocument() + expect(screen.getByText('$88.40')).toBeInTheDocument() + }) + it('shows the quiet empty state (never a fake table) when no PR links exist', async () => { getOverview.mockResolvedValue(makePayload()) render() diff --git a/app/renderer/sections/PullRequests.tsx b/app/renderer/sections/PullRequests.tsx index a04cd8f..5ac54c5 100644 --- a/app/renderer/sections/PullRequests.tsx +++ b/app/renderer/sections/PullRequests.tsx @@ -1,4 +1,5 @@ -import type { MouseEvent } from 'react' +import type { KeyboardEvent, MouseEvent } from 'react' +import { useState } from 'react' import { CliErrorPanel } from '../components/CliErrorPanel' import { EmptyNote } from '../components/EmptyState' @@ -22,11 +23,32 @@ function spanLabel(firstStarted: string, lastEnded: string): string { return start === end ? start : `${start} - ${end}` } +function sessionWord(n: number): string { + return n === 1 ? 'session' : 'sessions' +} + +// Up to two short model names, then a "+N" overflow tag; empty for no models. +function modelsLabel(models: string[]): string { + if (models.length <= 2) return models.join(', ') + return `${models.slice(0, 2).join(', ')} +${models.length - 2}` +} + function openPr(event: MouseEvent, url: string): void { event.preventDefault() + event.stopPropagation() void codeburn.openExternal(url) } +// Keyboard activation for the button-role row, guarded so Enter/Space fired on +// the inner link (its own control) never doubles up as a row toggle. +function rowKeyDown(event: KeyboardEvent, toggle: () => void): void { + if (event.target !== event.currentTarget) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggle() + } +} + /** Standalone entry: self-fetches the overview payload (used in tests). The App * passes its shared overview poll straight into PullRequestsContent instead. */ export function PullRequests({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) { @@ -59,32 +81,70 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq } function PrTable({ pullRequests }: { pullRequests: PullRequests }) { - const { rows, attributedCost, unattributedCost, distinctSessions } = pullRequests - const sessionWord = distinctSessions === 1 ? 'session' : 'sessions' + const { rows, distinctCost, distinctSessions, attributedCost, unattributedCost, otherPrCount, otherPrCost } = pullRequests + const [expandedUrl, setExpandedUrl] = useState(null) + + // A new-attribution payload carries `attributedCost`; an older by-reference + // payload omits it, so the rows are not summable and the footer must differ. + const summable = attributedCost !== undefined + const unattributed = unattributedCost ?? 0 + const otherCount = otherPrCount ?? 0 + const otherCost = otherPrCost ?? 0 + // Reconcile to the visible numbers: sum the rounded row costs (plus any + // capped-away remainder) so the footer total equals what the eye adds up. + const displayedAttributed = rows.reduce((sum, row) => sum + Number(row.cost.toFixed(2)), 0) + otherCost + return ( <> -
+
+ + - {rows.map(pr => )} + {rows.map(pr => ( + setExpandedUrl(current => current === pr.url ? null : pr.url)} + /> + ))} + {otherCount > 0 && ( + + + + + + + + + + )}
Pull requestModels Cost Sessions Calls Active
Other ({otherCount.toLocaleString('en-US')} more PRs){formatUsd(otherCost)}
-

- {formatUsd(attributedCost)} attributed to the rows above, across {distinctSessions.toLocaleString('en-US')} PR-linked {sessionWord}. - {' '}Each turn's cost goes to the PR it was working on, so the rows are summable. -

- {unattributedCost > 0 && ( -

Not tied to a specific PR: {formatUsd(unattributedCost)}

+ {summable ? ( +

+ {formatUsd(displayedAttributed)} attributed to the rows above, across {distinctSessions.toLocaleString('en-US')} PR-linked {sessionWord(distinctSessions)}. + {' '}Each turn's cost goes to the PR it was working on, so the rows are summable. +

+ ) : ( +

+ {formatUsd(distinctCost)} across {distinctSessions.toLocaleString('en-US')} distinct {sessionWord(distinctSessions)} produced pull requests. + {' '}Attribution is by reference: a session referencing several PRs counts toward each, so the rows above are not summed. +

+ )} + {unattributed > 0 && ( +

Not tied to a specific PR: {formatUsd(unattributed)}

)} ) @@ -92,18 +152,56 @@ function PrTable({ pullRequests }: { pullRequests: PullRequests }) { const APPROX_TITLE = 'Approximate: the transcript expired before per-turn capture, so this PR’s share is an even split of the whole session.' -function PrRowView({ pr }: { pr: PrRow }) { +function PrRowView({ pr, expanded, onToggle }: { pr: PrRow; expanded: boolean; onToggle: () => void }) { + const models = pr.models ?? [] + const categories = pr.categories ?? [] + const catMax = categories.length ? Math.max(...categories.map(cat => cat.cost)) : 0 + return ( - - - openPr(event, pr.url)}>{pr.label} - - - {pr.approx ? '~' : ''}{formatUsd(pr.cost)} - - {pr.sessions.toLocaleString('en-US')} - {pr.calls.toLocaleString('en-US')} - {spanLabel(pr.firstStarted, pr.lastEnded)} - + <> + rowKeyDown(event, onToggle)} + > + + openPr(event, pr.url)}>{pr.label} + + {models.length ? modelsLabel(models) : ''} + + {pr.approx ? '~' : ''}{formatUsd(pr.cost)} + + {pr.sessions.toLocaleString('en-US')} + {pr.calls.toLocaleString('en-US')} + {spanLabel(pr.firstStarted, pr.lastEnded)} + + + {expanded && ( + + + {categories.length > 0 ? ( +
+ {categories.map(cat => ( +
+ +
+ {cat.name} + {formatUsd(cat.cost)} +
+
+ ))} +
+ ) : ( +

No per-turn detail (estimated from a whole-session split).

+ )} + + + )} + ) } diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 3141862..5d813ff 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -580,9 +580,28 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } .ov-models th:first-child, .ov-models td:first-child { width: 100%; text-align: left; } .ov-models .ov-model-name { overflow: hidden; color: var(--ink); font-weight: var(--fw-medium); text-overflow: ellipsis; } .ov-models td.mono { font-family: var(--mono); color: var(--ink); } +.pr-scroll { overflow-x: auto; } +.pr-table { min-width: 640px; } .pr-link { color: var(--accent-text); font-weight: var(--fw-medium); text-decoration: none; cursor: pointer; } .pr-link:hover { text-decoration: underline; } +.pr-table th.pr-models, .pr-table td.pr-models { overflow: hidden; max-width: 180px; color: var(--mut); text-align: left; text-overflow: ellipsis; } .pr-table td.pr-span { color: var(--mut2); font-variant-numeric: tabular-nums; white-space: nowrap; } +.pr-table th.pr-chevron-cell, .pr-table td.pr-chevron-cell { width: 26px; padding: 0 8px; text-align: center; } +.pr-chevron { display: inline-block; color: var(--mut2); font-family: system-ui, sans-serif; font-size: 17px; line-height: 1; transition: transform 140ms ease; } +.pr-row { cursor: pointer; } +.pr-row:hover { background: var(--hover); } +.pr-row:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent); } +.pr-row[aria-expanded="true"] .pr-chevron { transform: rotate(90deg); } +.pr-table td.pr-detail-cell { height: auto; padding: 11px 14px; background: color-mix(in srgb, var(--panel) 84%, var(--hover)); text-align: left; white-space: normal; } +.pr-cats { display: flex; flex-direction: column; gap: 9px; max-width: 520px; } +.pr-cat { display: flex; flex-direction: column; gap: 5px; } +.pr-cat-bar { height: 3px; overflow: hidden; border-radius: 2px; background: var(--fill); } +.pr-cat-bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent); } +.pr-cat-main { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 12px; } +.pr-cat-name { overflow: hidden; color: var(--ink); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; } +.pr-cat-main strong { flex: 0 0 auto; font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; } +.pr-cat-empty { margin: 0; color: var(--mut2); font-size: var(--fs-meta); } +.pr-table td.pr-other-label { color: var(--mut2); font-weight: var(--fw-medium); text-align: left; } .pr-footnote { margin: 12px 2px 2px; color: var(--mut); font-size: var(--fs-meta); line-height: 1.55; } .pr-unattributed { margin: 4px 2px 2px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; } .opt-waste { min-width: 0; } diff --git a/src/main.ts b/src/main.ts index 6c4e57a..4bbe434 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2037,8 +2037,10 @@ program process.stdout.write('No sessions with captured PR links in this period. Links are captured as sessions are parsed; older transcripts gain them on their next re-parse.\n') return } - const { attributedCost, unattributedCost, sessions } = prLinkedTotals(projects) + const { unattributedCost, sessions } = prLinkedTotals(projects) const { renderTable: renderTextTable } = await import('./text-table.js') + const modelsCell = (models: string[]): string => + models.length === 0 ? '' : models.slice(0, 2).join(', ') + (models.length > 2 ? ` +${models.length - 2}` : '') const table = renderTextTable( [ { header: 'PR' }, @@ -2046,6 +2048,7 @@ program { header: 'Saved', right: true }, { header: 'Sessions', right: true }, { header: 'Calls', right: true }, + { header: 'Models' }, { header: 'First' }, { header: 'Last' }, ], @@ -2055,14 +2058,18 @@ program `$${r.savingsUSD.toFixed(2)}`, String(r.sessions), String(r.calls), + modelsCell(r.models), r.firstStarted.slice(0, 10), r.lastEnded.slice(0, 10), ]), ) + // Footer reconciles to the ROUNDED row values actually printed (not the + // exact float sum), so the visible column adds up to the stated total. + const shownAttributed = prRows.reduce((sum, r) => sum + Number(r.cost.toFixed(2)), 0) const approxNote = prRows.some(r => r.approx) ? ' ~ marks rows estimated from a whole-session even split (transcript expired before per-turn capture).' : '' - process.stdout.write(table + `\nRows sum to $${attributedCost.toFixed(2)} attributed across ${sessions} PR-linked session${sessions === 1 ? '' : 's'}. $${unattributedCost.toFixed(2)} of that spend was not tied to a specific PR.${approxNote}\n`) + process.stdout.write(table + `\nRows sum to $${shownAttributed.toFixed(2)} attributed across ${sessions} PR-linked session${sessions === 1 ? '' : 's'}. $${unattributedCost.toFixed(2)} of that spend was not tied to a specific PR.${approxNote}\n`) return } const rows = aggregateSessions(projects) diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 7b3dc78..d47482e 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -61,11 +61,17 @@ export type PullRequestsPayload = { /// `attributedCost + unattributedCost`; kept for backward compatibility. distinctCost: number distinctSessions: number - /// Sum of the per-PR rows' attributed cost (the rows ARE summable now). + /// Sum of EVERY PR's attributed cost (all rows, not just the sent top 20). attributedCost: number /// PR-linked spend not tied to any specific PR (pre-reference session /// overhead). `attributedCost + unattributedCost === distinctCost`. unattributedCost: number + /// Count of PRs beyond the sent `rows` (0 when nothing was capped). The app + /// renders an "Other (N more PRs)" summary row so the visible table still + /// reconciles to `attributedCost`. + otherPrCount: number + /// Attributed cost of those capped-away PRs (0 when nothing was capped). + otherPrCost: number } export type ProviderCost = { diff --git a/src/parser.ts b/src/parser.ts index 6aa9a56..ec8e1aa 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1857,14 +1857,19 @@ async function scanProjectDirs( } discoverProgress.finish() - if (readOnly) { - for (const [filePath, cached] of Object.entries(section.files)) { - if (allDiscoveredFiles.has(filePath)) continue - const dirName = cached.canonicalProjectName - ?? cached.turns[0]?.calls[0]?.project - ?? basename(dirname(filePath)) - unchangedFiles.push({ filePath, dirName, cached }) - } + // Orphans: cached sessions whose source file is no longer discovered. In + // read-only mode surface them all (the snapshot is authoritative, nothing is + // being pruned). In write mode surface only PR-bearing orphans: their transcript + // is gone and can never re-parse, but they carry attributable PR spend the by-PR + // report must keep (as a legacy even-split); the eviction below preserves the + // same set so `section.files` still holds them when summaries are built. + for (const [filePath, cached] of Object.entries(section.files)) { + if (allDiscoveredFiles.has(filePath)) continue + if (!readOnly && !cached.prLinks?.length) continue + const dirName = cached.canonicalProjectName + ?? cached.turns[0]?.calls[0]?.project + ?? basename(dirname(filePath)) + unchangedFiles.push({ filePath, dirName, cached }) } // Pre-seed dedup set from cached (unchanged) files @@ -2028,10 +2033,12 @@ async function scanProjectDirs( if (!readOnly && dirs.length > 0) { for (const cachedPath of Object.keys(section.files)) { - if (!allDiscoveredFiles.has(cachedPath)) { - delete section.files[cachedPath] - ;(diskCache as { _dirty?: boolean })._dirty = true - } + if (allDiscoveredFiles.has(cachedPath)) continue + // Keep PR-bearing orphans: their transcript is gone and can never re-parse, + // but they carry attributable PR spend (surfaced above as a legacy split). + if (section.files[cachedPath]?.prLinks?.length) continue + delete section.files[cachedPath] + ;(diskCache as { _dirty?: boolean })._dirty = true } } @@ -2051,8 +2058,23 @@ async function scanProjectDirs( // full ordered turn list) means a later date slice can drop the anchor turn // without the surviving turns losing their branch. let carriedBranch: string | undefined + // The PR set active going into the report range: carried across the FULL turn + // list, frozen the moment the first in-range turn is reached. Lets per-turn PR + // attribution seed from a reference made before the window (see + // attributeSessionPrSpend); the branch carry above solves the same problem. + let carriedPrRefs: string[] | undefined + let prRefsAtRangeStart: string[] | undefined + let frozePrRefs = !dateRange let classifiedTurns = cachedFile.turns.map(turn => { if (turn.gitBranch) carriedBranch = turn.gitBranch + if (dateRange && !frozePrRefs) { + const firstTs = turn.calls[0]?.timestamp + if (firstTs && new Date(firstTs) >= dateRange.start) { + prRefsAtRangeStart = carriedPrRefs + frozePrRefs = true + } + } + if (turn.prRefs?.length) carriedPrRefs = turn.prRefs return cachedTurnToClassified(turn, carriedBranch) }) // Captured from the FULL turn list, before the date slice below can drop the @@ -2080,6 +2102,7 @@ async function scanProjectDirs( session.agentType = cachedFile.agentType if (everHadBranch) session.everHadBranch = true if (cachedFile.prLinks?.length) session.prLinks = [...new Set(cachedFile.prLinks)].sort() + if (prRefsAtRangeStart?.length) session.prRefsAtRangeStart = prRefsAtRangeStart if (cachedFile.title) session.title = cachedFile.title if (session.apiCalls > 0) { diff --git a/src/session-cache.ts b/src/session-cache.ts index 5ac6244..5372442 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -364,21 +364,77 @@ function validateCache(raw: unknown): raw is SessionCache { return Object.values(o['providers'] as Record).every(validateProviderSection) } +// The immediately-prior versioned file. On the 5 -> 6 bump we adopt its +// still-relevant entries (see adoptV5Cache) rather than abandoning them; the +// file itself is never written or deleted (old binaries still own it). +const V5_CACHE_FILE = 'session-cache.v5.json' + +// v5-shaped validation: identical to validateCache but pinned to version 5. The +// per-turn schema is a superset (prRefs is optional), so a v5 file passes the +// section/file/turn validators unchanged. +function validateV5Cache(raw: unknown): raw is SessionCache { + if (!raw || typeof raw !== 'object') return false + const o = raw as Record + if (o['version'] !== 5) return false + if (!o['providers'] || typeof o['providers'] !== 'object' || Array.isArray(o['providers'])) return false + return Object.values(o['providers'] as Record).every(validateProviderSection) +} + +// One-time migration for the 5 -> 6 bump (per-turn prRefs capture). A fresh v6 +// cache would abandon v5 wholesale, so any PR-linked session whose transcript was +// since deleted would vanish instead of taking the by-PR legacy even-split path. +// Carry forward exactly the v5 entries whose source no longer exists AND that +// carry prLinks (they can never re-parse, but they hold attributable PR spend); +// present sources are intentionally dropped so they re-parse fresh under v6 and +// gain per-turn refs. Each carried section takes the CURRENT envFingerprint so +// the scan reuses it and appends the freshly-parsed present sources. The daily +// cache, which owns durable cost history, is not touched. +async function adoptV5Cache(): Promise { + try { + const raw = await readFile(join(getCacheDir(), V5_CACHE_FILE), 'utf-8') + const parsed = JSON.parse(raw) + if (!validateV5Cache(parsed)) return null + const migrated: SessionCache = { version: CACHE_VERSION, providers: {}, complete: false } + for (const [provider, section] of Object.entries(parsed.providers)) { + const files: Record = {} + for (const [path, file] of Object.entries(section.files)) { + if (!existsSync(path) && file.prLinks?.length) files[path] = file + } + migrated.providers[provider] = { + envFingerprint: computeEnvFingerprint(provider), + files, + ...(section.durable ? { durable: true } : {}), + } + } + return migrated + } catch { + return null + } +} + export async function loadCache(): Promise { try { const raw = await readFile(getCachePath(), 'utf-8') const parsed = JSON.parse(raw) - if (!validateCache(parsed)) return emptyCache() + if (!validateCache(parsed)) return afterMissingVersionedCache() return parsed } catch { - // Versioned file absent/unreadable: try a one-time adoption of the legacy - // unversioned file. validateCache requires version === CACHE_VERSION, so a - // different-version legacy file is ignored (left intact). We copy it into the - // versioned file once via saveCache; the legacy file is never modified. - return adoptLegacyCache() + return afterMissingVersionedCache() } } +// The versioned (v6) file is absent/unreadable. Prefer adopting the prior v5 +// file's expired-source PR orphans; failing that, fall back to the legacy +// unversioned file. Either way the versioned file is minted on the next save. +async function afterMissingVersionedCache(): Promise { + const v5 = await adoptV5Cache() + if (v5) return v5 + // validateCache requires version === CACHE_VERSION, so a different-version + // legacy file is ignored (left intact). We copy it into the versioned file once + // via saveCache; the legacy file is never modified. + return adoptLegacyCache() +} + async function adoptLegacyCache(): Promise { try { const raw = await readFile(getLegacyCachePath(), 'utf-8') diff --git a/src/sessions-report.ts b/src/sessions-report.ts index 084e56f..783e2a4 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -1,4 +1,6 @@ -import type { ProjectSummary, SessionSummary } from './types.js' +import { getShortModelName } from './models.js' +import { CATEGORY_LABELS } from './types.js' +import type { ProjectSummary, SessionSummary, TaskCategory } from './types.js' export type SessionRow = { sessionId: string @@ -99,6 +101,13 @@ export type PrRow = { /// (session-level prLinks but no surviving per-turn refs), so this row's share /// is an approximation rather than genuine turn-level attribution. approx: boolean + /// Short model names that processed this PR's attributed calls, ordered by + /// attributed cost descending, deduplicated. + models: string[] + /// Attributed cost per task category (from the turns' classification), ordered + /// by cost descending. Omitted for legacy approx rows: with no turn-level + /// attribution there is no honest per-category split. + categories?: Array<{ name: string; cost: number }> } const GITHUB_PR_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/ @@ -108,8 +117,13 @@ export function shortenPrUrl(url: string): string { return m ? `${m[1]}/${m[2]}#${m[3]}` : url } -/// One PR's slice of a session's spend. -export type PrContribution = { cost: number; calls: number; savingsUSD: number; approx: boolean } +/// One PR's slice of a session's spend. `models`/`categories` map a key (raw +/// model name / task category) to the attributed cost carried under it. +export type PrContribution = { + cost: number; calls: number; savingsUSD: number; approx: boolean + models: Map + categories: Map +} /// A single session's PR-attributed spend: `perUrl` is the turn-level split /// across the PRs it referenced; `unattributed` is the spend that belongs to no @@ -122,54 +136,87 @@ export type SessionPrAttribution = { // Minimal structural shape a SessionSummary satisfies, so the state machine is // unit-testable without constructing a full session fixture. type AttributableSession = { - turns: Array<{ prRefs?: string[]; assistantCalls: Array<{ costUSD: number; savingsUSD?: number }> }> + turns: Array<{ prRefs?: string[]; category?: string; assistantCalls: Array<{ costUSD: number; savingsUSD?: number; model?: string }> }> prLinks?: string[] totalCostUSD: number apiCalls: number totalSavingsUSD: number + /// The PR set carried into the in-range turn slice: the refs of the last turn + /// BEFORE the report's range start that referenced any PR. Seeds `current` so a + /// PR referenced before the range still owns its later, in-range, ref-less turns + /// (mirrors the branch carry-forward). Set by the parser; absent in unit tests. + prRefsAtRangeStart?: string[] } -function addContribution( - map: Map, - url: string, cost: number, calls: number, savingsUSD: number, approx: boolean, -): void { - const e = map.get(url) ?? { cost: 0, calls: 0, savingsUSD: 0, approx: false } - e.cost += cost - e.calls += calls - e.savingsUSD += savingsUSD - if (approx) e.approx = true - map.set(url, e) +function addToMap(m: Map, key: string, value: number): void { + m.set(key, (m.get(key) ?? 0) + value) +} + +function ensureContribution(map: Map, url: string): PrContribution { + let e = map.get(url) + if (!e) { + e = { cost: 0, calls: 0, savingsUSD: 0, approx: false, models: new Map(), categories: new Map() } + map.set(url, e) + } + return e +} + +// Split an integer `total` across `n` buckets as evenly as possible, giving the +// first `total % n` buckets the extra unit (largest-remainder, deterministic by +// bucket order). Keeps per-PR call counts integral so aggregated rows never +// over- or under-count from independent per-row rounding (a 1-call, 2-PR turn +// allocates [1, 0], not [0.5, 0.5] that would each round up to 1). +export function allocateEven(total: number, n: number): number[] { + const base = Math.floor(total / n) + const extra = total - base * n + return Array.from({ length: n }, (_, i) => base + (i < extra ? 1 : 0)) } /// Attribute a session's spend to the PRs it referenced, at TURN granularity. /// /// Walk the turns in order carrying `current` = the PR set of the most recent -/// turn that referenced any PR. Each turn's cost/calls/savings are attributed to -/// `current`, split evenly across a multi-PR set (a merge-sweep turn touching -/// several PRs at once). Turns before the first reference land in `unattributed` -/// (genuine session overhead: exploration, unrelated work). +/// turn that referenced any PR (seeded from `prRefsAtRangeStart` so a reference +/// made before the report window still owns its in-range follow-up turns). Each +/// turn's cost/savings are split evenly across a multi-PR set (a merge-sweep turn +/// touching several PRs); calls are split by largest-remainder so they stay whole. +/// Each contribution also records the models of its calls and the turn's task +/// category, both weighted by the same split share. Turns before the first +/// reference land in `unattributed` (genuine session overhead). /// /// Legacy fallback: a session whose transcript already expired keeps its /// session-level `prLinks` but has NO per-turn `prRefs`. With no turn boundaries -/// to attribute by, split the whole session evenly across its prLinks and mark -/// every portion `approx` so surfaces can flag it honestly. +/// to attribute by, split the whole session evenly across its prLinks, mark every +/// portion `approx`, and carry the session's model union (its calls still name +/// their models) but NO category breakdown, since none can be honestly assigned. export function attributeSessionPrSpend(session: AttributableSession): SessionPrAttribution { const perUrl = new Map() const unattributed = { cost: 0, calls: 0, savingsUSD: 0 } - const hasTurnRefs = session.turns.some(t => t.prRefs?.length) + const hasTurnRefs = session.turns.some(t => t.prRefs?.length) || !!session.prRefsAtRangeStart?.length if (!hasTurnRefs) { const links = session.prLinks if (links?.length) { - const share = 1 / links.length - for (const url of links) { - addContribution(perUrl, url, session.totalCostUSD * share, session.apiCalls * share, session.totalSavingsUSD * share, true) + const legacyModels = new Map() + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + if (call.model) addToMap(legacyModels, call.model, call.costUSD) + } } + const share = 1 / links.length + const callAlloc = allocateEven(session.apiCalls, links.length) + links.forEach((url, i) => { + const e = ensureContribution(perUrl, url) + e.cost += session.totalCostUSD * share + e.calls += callAlloc[i]! + e.savingsUSD += session.totalSavingsUSD * share + e.approx = true + for (const [m, mc] of legacyModels) addToMap(e.models, m, mc * share) + }) } return { perUrl, unattributed } } - let current: string[] | null = null + let current: string[] | null = session.prRefsAtRangeStart?.length ? session.prRefsAtRangeStart : null for (const turn of session.turns) { if (turn.prRefs?.length) current = turn.prRefs const cost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) @@ -182,8 +229,20 @@ export function attributeSessionPrSpend(session: AttributableSession): SessionPr unattributed.savingsUSD += savings continue } + const modelCostInTurn = new Map() + for (const call of turn.assistantCalls) { + if (call.model) addToMap(modelCostInTurn, call.model, call.costUSD) + } const share = 1 / current.length - for (const url of current) addContribution(perUrl, url, cost * share, calls * share, savings * share, false) + const callAlloc = allocateEven(calls, current.length) + current.forEach((url, i) => { + const e = ensureContribution(perUrl, url) + e.cost += cost * share + e.calls += callAlloc[i]! + e.savingsUSD += savings * share + if (turn.category) addToMap(e.categories, turn.category, cost * share) + for (const [m, mc] of modelCostInTurn) addToMap(e.models, m, mc * share) + }) } return { perUrl, unattributed } } @@ -191,27 +250,35 @@ export function attributeSessionPrSpend(session: AttributableSession): SessionPr /// Spend attributed to each pull request at turn granularity (see /// attributeSessionPrSpend). Rows carry ATTRIBUTED cost/calls and ARE summable; /// `sessions` counts the distinct sessions that contributed any spend to the PR; -/// `approx` marks rows fed by the legacy even-split fallback. Sorted by cost, desc. +/// `approx` marks rows fed by the legacy even-split fallback; `models` and +/// `categories` are the attributed model/category breakdowns. Sorted by cost, desc. export function aggregateByPr(projects: ProjectSummary[]): PrRow[] { const byUrl = new Map; firstStarted: string; lastEnded: string + models: Map; categories: Map }>() for (const project of projects) { for (const session of project.sessions) { if (!session.prLinks?.length) continue + // Key on project + sessionId: a transcript basename (sessionId) can repeat + // across projects, so sessionId alone would undercount distinct sessions. + const sessionKey = `${session.project}${session.sessionId}` const { perUrl } = attributeSessionPrSpend(session) for (const [url, c] of perUrl) { if (c.cost === 0 && c.calls === 0 && c.savingsUSD === 0) continue const row = byUrl.get(url) ?? { cost: 0, savingsUSD: 0, calls: 0, approx: false, sessions: new Set(), firstStarted: session.firstTimestamp, lastEnded: session.lastTimestamp, + models: new Map(), categories: new Map(), } row.cost += c.cost row.savingsUSD += c.savingsUSD row.calls += c.calls - row.sessions.add(session.sessionId) + row.sessions.add(sessionKey) if (c.approx) row.approx = true + for (const [m, mc] of c.models) addToMap(row.models, m, mc) + for (const [cat, cc] of c.categories) addToMap(row.categories, cat, cc) if (session.firstTimestamp < row.firstStarted) row.firstStarted = session.firstTimestamp if (session.lastTimestamp > row.lastEnded) row.lastEnded = session.lastTimestamp byUrl.set(url, row) @@ -219,13 +286,25 @@ export function aggregateByPr(projects: ProjectSummary[]): PrRow[] { } } return [...byUrl.entries()] - .map(([url, r]) => ({ - url, label: shortenPrUrl(url), - cost: r.cost, savingsUSD: r.savingsUSD, - sessions: r.sessions.size, calls: Math.round(r.calls), - firstStarted: r.firstStarted, lastEnded: r.lastEnded, - approx: r.approx, - })) + .map(([url, r]) => { + // Collapse raw model names to short display names, summing costs that map + // to the same short name, then order by attributed cost. + const shortCosts = new Map() + for (const [raw, mc] of r.models) addToMap(shortCosts, getShortModelName(raw), mc) + const models = [...shortCosts.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name) + const categories = [...r.categories.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([cat, cost]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, cost })) + return { + url, label: shortenPrUrl(url), + cost: r.cost, savingsUSD: r.savingsUSD, + sessions: r.sessions.size, calls: r.calls, + firstStarted: r.firstStarted, lastEnded: r.lastEnded, + approx: r.approx, + models, + ...(categories.length ? { categories } : {}), + } + }) .sort((a, b) => b.cost - a.cost) } diff --git a/src/types.ts b/src/types.ts index b555ce4..609c600 100644 --- a/src/types.ts +++ b/src/types.ts @@ -207,6 +207,12 @@ export type SessionSummary = { /// GitHub PR URLs captured from the session transcript (session-level, /// deduplicated). Absent when none were observed. prLinks?: string[] + /// The PR set active at the start of the in-range turn slice: the refs of the + /// last turn BEFORE the report's range start that referenced any PR. Captured + /// pre-filter (like `everHadBranch`) so per-turn PR attribution can carry a + /// reference made before the window into its later, in-range, ref-less turns. + /// Absent when no PR was referenced before the range (or no range filter). + prRefsAtRangeStart?: string[] /// Human session title captured from the transcript (last ai-title entry). /// Absent when the transcript never produced one. title?: string diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index a78c383..4e70848 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -666,12 +666,16 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: const prRows = aggregateByPr(scanProjects) if (prRows.length > 0) { const prTotals = prLinkedTotals(scanProjects) + const shownRows = prRows.slice(0, TOP_PULL_REQUESTS) + const otherRows = prRows.slice(TOP_PULL_REQUESTS) currentData.pullRequests = { - rows: prRows.slice(0, TOP_PULL_REQUESTS), + rows: shownRows, distinctCost: prTotals.cost, distinctSessions: prTotals.sessions, attributedCost: prTotals.attributedCost, unattributedCost: prTotals.unattributedCost, + otherPrCount: otherRows.length, + otherPrCost: otherRows.reduce((sum, r) => sum + r.cost, 0), } } const branchRows = aggregateByBranch(scanProjects) diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index 2958489..81b35e3 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -46,6 +46,29 @@ describe('buildMenubarPayload', () => { expect(payload.current.outputTokens).toBe(675600) }) + it('passes the pull-requests payload (models, categories, cap remainder) through verbatim', () => { + const period: PeriodData = { + ...emptyPeriod('7 Days'), + pullRequests: { + rows: [ + { url: 'https://github.com/o/r/pull/1', label: 'o/r#1', cost: 40, savingsUSD: 0, sessions: 1, calls: 12, firstStarted: '2026-07-20T10:00:00Z', lastEnded: '2026-07-20T11:00:00Z', approx: false, models: ['fable', 'opus'], categories: [{ name: 'Coding', cost: 30 }, { name: 'Debugging', cost: 10 }] }, + ], + distinctCost: 45, + distinctSessions: 1, + attributedCost: 40, + unattributedCost: 5, + otherPrCount: 3, + otherPrCost: 12.5, + }, + } + const payload = buildMenubarPayload(period, [], null) + expect(payload.current.pullRequests).toEqual(period.pullRequests) + expect(payload.current.pullRequests!.rows[0]!.models).toEqual(['fable', 'opus']) + expect(payload.current.pullRequests!.rows[0]!.categories).toEqual([{ name: 'Coding', cost: 30 }, { name: 'Debugging', cost: 10 }]) + expect(payload.current.pullRequests!.otherPrCount).toBe(3) + expect(payload.current.pullRequests!.otherPrCost).toBe(12.5) + }) + it('exposes period-scoped cache tokens on current, decoupled from the 365-day history backfill (#583)', () => { const period: PeriodData = { label: '30 Days', diff --git a/tests/parser-incremental-append.test.ts b/tests/parser-incremental-append.test.ts index 68ba35d..8c0e24c 100644 --- a/tests/parser-incremental-append.test.ts +++ b/tests/parser-incremental-append.test.ts @@ -77,6 +77,10 @@ function asstLine( const readBlock = (file: string) => ({ type: 'tool_use', name: 'Read', input: { file_path: file } }) const bashBlock = (cmd: string) => ({ type: 'tool_use', name: 'Bash', input: { command: cmd } }) +function prLinkLine(ts: string, url: string): string { + return JSON.stringify({ type: 'pr-link', sessionId: 'sess-1', timestamp: ts, cwd: CWD, prUrl: url }) +} + // A representative multi-turn session: MCP inventory, tools, bash, and a // streaming re-emit of one assistant message (same id, updated usage) inside a // turn — exercises dedup, breakdowns, and turn assembly. @@ -150,6 +154,59 @@ describe('incremental append parsing', () => { await rm(warmCache, { recursive: true, force: true }) }) + it('PR-REFS: survive the incremental append path (continuation merge unions refs)', async () => { + const warmCache = await mkdtemp(join(tmpdir(), 'incr-pr-')) + // Base: one turn that creates PR-1. + await writeFile(sessionPath, + userLine('2026-05-01T10:00:01.000Z', 'ship PR one') + '\n' + + asstLine('msg-a', '2026-05-01T10:00:02.000Z', { input_tokens: 100, output_tokens: 20 }, [bashBlock('gh pr create')]) + '\n' + + prLinkLine('2026-05-01T10:00:03.000Z', 'https://github.com/o/r/pull/1') + '\n') + await parseWith(warmCache) + + // Append a continuation of that same turn (no leading user message) that + // references PR-2, then a fresh turn that references PR-3. + await appendFile(sessionPath, + asstLine('msg-b', '2026-05-01T10:00:04.000Z', { input_tokens: 50, output_tokens: 10 }, [bashBlock('gh pr create')]) + '\n' + + prLinkLine('2026-05-01T10:00:05.000Z', 'https://github.com/o/r/pull/2') + '\n' + + userLine('2026-05-01T10:10:00.000Z', 'ship PR three') + '\n' + + asstLine('msg-c', '2026-05-01T10:10:02.000Z', { input_tokens: 80, output_tokens: 20 }, [bashBlock('gh pr create')]) + '\n' + + prLinkLine('2026-05-01T10:10:03.000Z', 'https://github.com/o/r/pull/3') + '\n') + + readLineCalls.length = 0 + const warm = await parseWith(warmCache) + expect(offsetsFor(sessionPath).some(o => o !== undefined && o > 0)).toBe(true) // took the append path + const cold = await coldFullReparse() + expect(warm).toEqual(cold) + + const turns = warm[0]!.sessions[0]!.turns + expect(turns[0]!.prRefs).toEqual(['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2']) + expect(turns[1]!.prRefs).toEqual(['https://github.com/o/r/pull/3']) + await rm(warmCache, { recursive: true, force: true }) + }) + + it('PR-REFS: survive when a straddled append falls back to a full re-parse', async () => { + const warmCache = await mkdtemp(join(tmpdir(), 'incr-pr2-')) + await writeFile(sessionPath, + userLine('2026-05-01T10:00:01.000Z', 'ship PR one') + '\n' + + asstLine('msg-a', '2026-05-01T10:00:02.000Z', { input_tokens: 100, output_tokens: 20 }, [bashBlock('gh pr create')]) + '\n' + + prLinkLine('2026-05-01T10:00:03.000Z', 'https://github.com/o/r/pull/1') + '\n') + await parseWith(warmCache) + + // Re-emit msg-a (an id already committed in the cached prefix) -> straddle -> + // the shortcut is abandoned and the file re-parses from byte 0. + await appendFile(sessionPath, + asstLine('msg-a', '2026-05-01T10:00:02.500Z', { input_tokens: 100, output_tokens: 40 }, [bashBlock('gh pr create')]) + '\n' + + prLinkLine('2026-05-01T10:00:06.000Z', 'https://github.com/o/r/pull/2') + '\n') + + const warm = await parseWith(warmCache) + const cold = await coldFullReparse() + expect(warm).toEqual(cold) + expect(warm[0]!.sessions[0]!.turns[0]!.prRefs).toEqual([ + 'https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2', + ]) + await rm(warmCache, { recursive: true, force: true }) + }) + it('EDGE: append after a previously-torn line completes still equals cold', async () => { const warmCache = await mkdtemp(join(tmpdir(), 'incr-warm2-')) diff --git a/tests/session-cache-v5-adoption.test.ts b/tests/session-cache-v5-adoption.test.ts new file mode 100644 index 0000000..3d97c19 --- /dev/null +++ b/tests/session-cache-v5-adoption.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { parseAllSessions, clearSessionCache } from '../src/parser.js' +import { aggregateByPr } from '../src/sessions-report.js' +import { loadPricing } from '../src/models.js' + +// Finding 1: the 5 -> 6 session-cache bump must not make PR-linked sessions whose +// transcript has since expired VANISH. loadCache adopts such expired-source +// entries from session-cache.v5.json, and the claude scan preserves + surfaces +// them so the by-PR legacy even-split path is actually reachable. + +let tmpDir: string +let cacheDir: string +let configDir: string + +beforeEach(async () => { + clearSessionCache() + tmpDir = await mkdtemp(join(tmpdir(), 'v5-adopt-')) + cacheDir = join(tmpDir, 'cache') + configDir = join(tmpDir, 'claude') + await mkdir(cacheDir, { recursive: true }) + // A present, non-PR session so discovery finds a project dir (dirs.length > 0, + // exercising the eviction path the orphan must survive). + const presentDir = join(configDir, 'projects', 'present-proj') + await mkdir(presentDir, { recursive: true }) + await writeFile(join(presentDir, 'present.jsonl'), + JSON.stringify({ type: 'user', sessionId: 'present', timestamp: '2026-07-20T09:00:00.000Z', cwd: '/present', message: { role: 'user', content: 'hi' } }) + '\n' + + JSON.stringify({ type: 'assistant', sessionId: 'present', timestamp: '2026-07-20T09:00:01.000Z', cwd: '/present', message: { id: 'p1', type: 'message', role: 'assistant', model: 'claude-opus-4-6', content: [], usage: { input_tokens: 10, output_tokens: 5 } } }) + '\n') + process.env['CLAUDE_CONFIG_DIR'] = configDir + process.env['CODEBURN_CACHE_DIR'] = cacheDir +}) + +afterEach(async () => { + clearSessionCache() + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CODEBURN_CACHE_DIR'] + await rm(tmpDir, { recursive: true, force: true }) +}) + +function cachedCall(dedup: string, cost: number): Record { + return { + provider: 'claude', model: 'claude-opus-4-6', + usage: { inputTokens: 100, outputTokens: 50, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0 }, + costUSD: cost, speed: 'standard', timestamp: '2026-07-20T10:00:00.000Z', + tools: [], bashCommands: [], skills: [], subagentTypes: [], deduplicationKey: dedup, + } +} + +describe('v5 -> v6 cache adoption of expired PR sessions', () => { + it('keeps a PR-linked session whose transcript is gone, as a legacy approx split', async () => { + await loadPricing() + // A v5 cache whose one entry points at a transcript that no longer exists. + const gonePath = join(configDir, 'projects', 'gone-proj', 'gone.jsonl') + const v5 = { + version: 5, + complete: true, + providers: { + claude: { + envFingerprint: 'stale-v5-fingerprint', + files: { + [gonePath]: { + fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, + mcpInventory: [], + canonicalCwd: '/gone/proj', + canonicalProjectName: 'gone-proj', + prLinks: ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2'], + // Two calls, $40 each -> session total $80, no per-turn prRefs (v5). + turns: [{ + timestamp: '2026-07-20T10:00:00.000Z', sessionId: 'gone', userMessage: 'shipped work', + calls: [cachedCall('k1', 40), cachedCall('k2', 40)], + }], + }, + }, + }, + }, + } + await writeFile(join(cacheDir, 'session-cache.v5.json'), JSON.stringify(v5)) + + const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-20T23:59:59Z') } + const projects = await parseAllSessions(range, 'claude') + const rows = aggregateByPr(projects) + + // Both PRs survive, each carrying the even-split half of the $80 session, and + // are flagged approx (legacy) with no category breakdown. + expect(rows).toHaveLength(2) + expect(rows.every(r => r.approx)).toBe(true) + expect(rows.every(r => r.categories === undefined)).toBe(true) + expect(rows[0]!.cost).toBeCloseTo(40, 6) + expect(rows[1]!.cost).toBeCloseTo(40, 6) + // Model union is still surfaced on legacy rows. + expect(rows[0]!.models.length).toBeGreaterThan(0) + }) +}) diff --git a/tests/sessions-by-pr.test.ts b/tests/sessions-by-pr.test.ts index 21f210a..082d1de 100644 --- a/tests/sessions-by-pr.test.ts +++ b/tests/sessions-by-pr.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { aggregateByPr, attributeSessionPrSpend, prLinkedTotals, shortenPrUrl } from '../src/sessions-report.js' +import { aggregateByPr, allocateEven, attributeSessionPrSpend, prLinkedTotals, shortenPrUrl } from '../src/sessions-report.js' import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from '../src/types.js' const A = 'https://github.com/o/r/pull/1' @@ -209,6 +209,110 @@ describe('aggregateByPr (turn-level attribution)', () => { }) }) +describe('range-carry seed (finding 2)', () => { + it('seeds current from a PR referenced before the range, not a legacy split', () => { + // turn1 ref A, turn2 ref B are both before the range; the parser passes B as + // the seed. The 8 in-range ref-less turns ($100) must all go to B, not split + // $50/$50 approx across A and B. + const { perUrl, unattributed } = attributeSessionPrSpend({ + prLinks: [A, B], totalCostUSD: 100, apiCalls: 8, totalSavingsUSD: 0, + prRefsAtRangeStart: [B], + turns: Array.from({ length: 8 }, () => ({ category: 'coding', assistantCalls: [{ costUSD: 12.5, model: 'm' }] })), + }) + expect(perUrl.get(B)!.cost).toBeCloseTo(100, 6) + expect(perUrl.get(B)!.approx).toBe(false) + expect(perUrl.has(A)).toBe(false) + expect(unattributed.cost).toBe(0) + }) + + it('lets an in-range reference override the seed', () => { + const { perUrl } = attributeSessionPrSpend({ + prLinks: [A, B], totalCostUSD: 0, apiCalls: 0, totalSavingsUSD: 0, + prRefsAtRangeStart: [A], + turns: [ + { assistantCalls: [{ costUSD: 10 }] }, // seeded -> A + { prRefs: [B], assistantCalls: [{ costUSD: 20 }] }, // switch -> B + { assistantCalls: [{ costUSD: 5 }] }, // carries B + ], + }) + expect(perUrl.get(A)!.cost).toBeCloseTo(10, 6) + expect(perUrl.get(B)!.cost).toBeCloseTo(25, 6) + }) +}) + +describe('call allocation (finding 5)', () => { + it('allocateEven gives the remainder to the first buckets and sums to total', () => { + expect(allocateEven(1, 2)).toEqual([1, 0]) + expect(allocateEven(5, 2)).toEqual([3, 2]) + expect(allocateEven(4, 2)).toEqual([2, 2]) + expect(allocateEven(0, 3)).toEqual([0, 0, 0]) + expect(allocateEven(7, 3)).toEqual([3, 2, 2]) + }) + + it('a 1-call, 2-PR turn stays whole (no 0.5 that rounds up to 1 on each row)', () => { + const { perUrl } = attributeSessionPrSpend({ + prLinks: [A, B], totalCostUSD: 0, apiCalls: 0, totalSavingsUSD: 0, + turns: [{ prRefs: [A, B], category: 'coding', assistantCalls: [{ costUSD: 4 }] }], + }) + expect(perUrl.get(A)!.calls + perUrl.get(B)!.calls).toBe(1) + expect(perUrl.get(A)!.calls).toBe(1) + expect(perUrl.get(B)!.calls).toBe(0) + }) +}) + +describe('models + categories attribution', () => { + it('records per-model attributed cost and spreads a multi-PR turn to each PR', () => { + const { perUrl } = attributeSessionPrSpend({ + prLinks: [A, B], totalCostUSD: 0, apiCalls: 0, totalSavingsUSD: 0, + turns: [ + { prRefs: [A], category: 'coding', assistantCalls: [{ costUSD: 10, model: 'claude-opus-4-6' }, { costUSD: 5, model: 'claude-haiku-4' }] }, + { prRefs: [A, B], category: 'coding', assistantCalls: [{ costUSD: 20, model: 'claude-opus-4-6' }] }, + ], + }) + // A: opus 10 + haiku 5 + half of turn2 opus (10) = opus 20, haiku 5 + expect(perUrl.get(A)!.models.get('claude-opus-4-6')).toBeCloseTo(20, 6) + expect(perUrl.get(A)!.models.get('claude-haiku-4')).toBeCloseTo(5, 6) + // B: half of turn2 opus = 10 + expect(perUrl.get(B)!.models.get('claude-opus-4-6')).toBeCloseTo(10, 6) + }) + + it('accumulates category cost per PR (turn-level) and omits categories for legacy', () => { + const { perUrl } = attributeSessionPrSpend({ + prLinks: [A], totalCostUSD: 0, apiCalls: 0, totalSavingsUSD: 0, + turns: [ + { prRefs: [A], category: 'coding', assistantCalls: [{ costUSD: 10 }] }, + { category: 'debugging', assistantCalls: [{ costUSD: 6 }] }, + ], + }) + expect(perUrl.get(A)!.categories.get('coding')).toBeCloseTo(10, 6) + expect(perUrl.get(A)!.categories.get('debugging')).toBeCloseTo(6, 6) + + const legacy = attributeSessionPrSpend({ + prLinks: [A, B], totalCostUSD: 100, apiCalls: 8, totalSavingsUSD: 0, + turns: [{ assistantCalls: [{ costUSD: 100, model: 'claude-opus-4-6' }] }], + }) + expect(legacy.perUrl.get(A)!.categories.size).toBe(0) // no faked categories + expect(legacy.perUrl.get(A)!.models.get('claude-opus-4-6')).toBeCloseTo(50, 6) // model union still split + }) + + it('exposes short model names and display category labels on aggregated rows', () => { + const rows = aggregateByPr([project([ + sessionWithTurns('s', [A], [cturn(10, 1, [A])]), + ])]) + expect(rows[0]!.models.length).toBe(1) + expect(rows[0]!.categories).toEqual([{ name: 'Coding', cost: 10 }]) + }) +}) + +describe('distinct-session keying (finding 7)', () => { + it('counts two same-sessionId sessions in different projects as two', () => { + const s1 = sessionWithTurns('same-id', [A], [cturn(10, 1, [A])]) + const s2 = { ...sessionWithTurns('same-id', [A], [cturn(10, 1, [A])]), project: 'other' } + const rows = aggregateByPr([project([s1, s2])]) + expect(rows[0]!.sessions).toBe(2) + }) +}) + describe('prLinkedTotals', () => { it('splits attributed vs unattributed and counts each PR-linked session once', () => { const totals = prLinkedTotals([project([