diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 6e95539..0a59d68 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -220,11 +220,15 @@ export type MenubarPayload = { skills: Array<{ name: string; turns: number; cost: number }> subagents: Array<{ name: string; calls: number; cost: number }> mcpServers: Array<{ name: string; calls: number }> - // Spend by referenced pull request (top 20 by cost) plus the multi-link-safe - // distinct total. Optional: older CLIs omit it, and it is absent when no PR - // links were observed. Rows are by-reference — a session referencing several - // PRs counts toward each — so `rows[].cost` must never be summed; use - // `distinctCost`/`distinctSessions` for a total. + // Spend by referenced pull request (top 20 by cost), attributed at turn + // 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). `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 @@ -235,9 +239,16 @@ export type MenubarPayload = { calls: number firstStarted: string lastEnded: string + approx?: boolean + models?: string[] + categories?: Array<{ name: string; cost: number }> }> distinctCost: number distinctSessions: 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 264884a..4ce99db 100644 --- a/app/renderer/sections/PullRequests.test.tsx +++ b/app/renderer/sections/PullRequests.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -46,11 +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: 300.75, + 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', () => { @@ -73,23 +84,182 @@ 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('states the distinct-total footer explaining by-reference attribution', async () => { + 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('closes an open expansion when the period changes the PR set', async () => { + const changed: PrPayload = { ...SAMPLE, rows: [SAMPLE.rows[0]!] } // #781 dropped + getOverview.mockImplementation((period: string) => Promise.resolve(makePayload(period === 'lifetime' ? SAMPLE : changed))) + const { rerender } = render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + await userEvent.click(rowForLink(link)) + expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true') + + rerender() + // The new period drops #781, so the PR set changes and the stale expansion + // resets once the new data lands (wait for the breakdown to disappear). + await waitFor(() => expect(screen.queryByText('Feature work')).toBeNull()) + const link2 = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + expect(rowForLink(link2)).toHaveAttribute('aria-expanded', 'false') + }) + + it('closes an open expansion on a period switch even when the PR set is identical', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + const { rerender } = render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + await userEvent.click(rowForLink(link)) + expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true') + + // Same rows come back for the new period; the expansion must still reset, + // since the row's underlying numbers may differ across periods. + rerender() + await waitFor(() => expect(screen.queryByText('Feature work')).toBeNull()) + const link2 = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + expect(rowForLink(link2)).toHaveAttribute('aria-expanded', 'false') + }) + + 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 () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + const note = await screen.findByText(/attributed to the rows above/) + expect(note.textContent).toContain('$330.75') + expect(note.textContent).toContain('3 PR-linked sessions') + expect(note.textContent).toContain('summable') + expect(screen.getByText(/Not tied to a specific PR/).textContent).toContain('$45.30') + }) + + it('marks an approximate (legacy) row with a ~ prefix and a tooltip', 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 cost = await screen.findByText('~$12.50') + expect(cost).toHaveAttribute('title') + // A zero unattributed remainder hides the muted line. + 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('$300.75') - expect(note.textContent).toContain('3 distinct sessions') - expect(note.textContent).toContain('counts toward each') + 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 () => { @@ -101,7 +271,7 @@ describe('PullRequests', () => { }) it('shows the empty state when the PR array is present but empty', async () => { - getOverview.mockResolvedValue(makePayload({ rows: [], distinctCost: 0, distinctSessions: 0 })) + getOverview.mockResolvedValue(makePayload({ rows: [], distinctCost: 0, distinctSessions: 0, attributedCost: 0, unattributedCost: 0 })) render() expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument() diff --git a/app/renderer/sections/PullRequests.tsx b/app/renderer/sections/PullRequests.tsx index 53b9796..cedd13a 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 { useEffect, 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 }) { @@ -34,7 +56,9 @@ export function PullRequests({ period, provider, range = null }: { period: Perio () => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider), [period, provider, range?.from, range?.to], ) - return + // The key remounts the content on a period/provider/range switch so row state + // (an open expansion) never survives onto the same PR rendered from new data. + return } export function PullRequestsContent({ overview }: { overview: Polled }) { @@ -59,44 +83,136 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq } function PrTable({ pullRequests }: { pullRequests: PullRequests }) { - const { rows, distinctCost, distinctSessions } = pullRequests - const sessionWord = distinctSessions === 1 ? 'session' : 'sessions' + const { rows, distinctCost, distinctSessions, attributedCost, unattributedCost, otherPrCount, otherPrCost } = pullRequests + const [expandedUrl, setExpandedUrl] = useState(null) + // Reset any open expansion when the PR set changes (a period/provider switch or + // a refresh that alters the list): a stale expandedUrl would otherwise linger + // pointing at a row that is no longer present. + const rowKey = rows.map(row => row.url).join('|') + useEffect(() => { setExpandedUrl(null) }, [rowKey]) + + // 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 && ( + // A muted summary line, kept out of the sorted rows: its cost is an + // aggregate of the capped-away PRs and can exceed a visible row. + + + + + + + + + + + + )}
Pull requestModels Cost Sessions Calls Active
Other ({otherCount.toLocaleString('en-US')} more PRs){formatUsd(otherCost)}
-

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

+ {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)}

+ )} ) } -function PrRowView({ pr }: { pr: PrRow }) { +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, 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} - - {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 68c4e3d..ed45b6b 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -580,10 +580,31 @@ 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 tfoot .pr-other-row td { border-top: 1px solid var(--line); color: var(--mut2); font-style: italic; } +.pr-table td.pr-other-label { color: var(--mut2); font-weight: var(--fw-medium); font-style: italic; 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; } .opt-summary { padding: 0 0 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; } .opt-findings { display: grid; min-width: 0; } diff --git a/src/main.ts b/src/main.ts index 4e059bc..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 { cost, 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,20 +2048,28 @@ program { header: 'Saved', right: true }, { header: 'Sessions', right: true }, { header: 'Calls', right: true }, + { header: 'Models' }, { header: 'First' }, { header: 'Last' }, ], prRows.map(r => [ r.label, - `$${r.cost.toFixed(2)}`, + `${r.approx ? '~' : ''}$${r.cost.toFixed(2)}`, `$${r.savingsUSD.toFixed(2)}`, String(r.sessions), String(r.calls), + modelsCell(r.models), r.firstStarted.slice(0, 10), r.lastEnded.slice(0, 10), ]), ) - process.stdout.write(table + `\nDistinct PR-linked spend: $${cost.toFixed(2)} across ${sessions} session${sessions === 1 ? '' : 's'}. A session referencing several PRs counts toward each, so rows exceed this total when links overlap.\n`) + // 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 $${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 82adf71..d47482e 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -43,9 +43,9 @@ export type PeriodData = { /// Share (0-1) of cost-bearing calls that resolved a price. pricingCoverage?: number /// Spend attributed by referenced pull request (from Claude session - /// transcripts). Rows are by-reference — a session referencing several PRs - /// counts toward EACH — so never sum them; `distinctCost`/`distinctSessions` - /// are the multi-link-safe total. Absent when no PR links were observed. + /// transcripts), at turn granularity. Rows carry attributed cost/calls and ARE + /// summable; `attributedCost`/`unattributedCost` split the PR-linked spend. + /// Absent when no PR links were observed. pullRequests?: PullRequestsPayload /// Per-branch spend, last-seen branch carried forward across each session's /// turns. A `null` branch is unbranched spend inside a branch-bearing session. @@ -57,11 +57,21 @@ export type PeriodData = { export type PullRequestsPayload = { /// Per-PR rows, cost-descending, capped at the top 20 by the producer. rows: PrRow[] - /// Distinct-session spend across every PR-linked session — the figure safe to - /// present as a total, since the per-PR rows double-count sessions that - /// reference more than one PR. + /// Distinct-session spend across every PR-linked session. Equals + /// `attributedCost + unattributedCost`; kept for backward compatibility. distinctCost: number distinctSessions: number + /// 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 807639d..ec8e1aa 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -912,6 +912,12 @@ export function compactEntry(raw: JournalEntry): JournalEntry { if (raw.cwd !== undefined) entry.cwd = raw.cwd // Preserved so groupIntoTurns can stamp each turn's git branch (rich capture). if (typeof raw.gitBranch === 'string' && raw.gitBranch) entry.gitBranch = raw.gitBranch + // Preserved so groupIntoTurns can attribute each PR reference to its turn. + // Only `pr-link` entries carry `prUrl`; every other field of theirs is dropped. + if (raw.type === 'pr-link') { + const prUrl = (raw as Record)['prUrl'] + if (typeof prUrl === 'string' && prUrl) (entry as Record)['prUrl'] = prUrl + } const att = (raw as Record)['attachment'] if (att && typeof att === 'object') { @@ -1440,6 +1446,10 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set, // from the user entry (gitBranch is on every user/assistant entry); a // continuation turn with no leading user text falls back to its first call. let currentBranch: string | undefined + // GitHub PR URLs referenced within the turn currently being accumulated. A + // `pr-link` entry is emitted after the assistant creates/references a PR, so it + // lands inside the same turn (before the next user message) and attaches here. + let currentPrRefs: string[] = [] for (const entry of entries) { const entryBranch = typeof entry.gitBranch === 'string' && entry.gitBranch ? entry.gitBranch : undefined @@ -1453,6 +1463,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set, timestamp: currentTimestamp, sessionId: currentSessionId, ...(currentBranch ? { gitBranch: currentBranch } : {}), + ...(currentPrRefs.length > 0 ? { prRefs: [...currentPrRefs].sort() } : {}), }) } currentUserMessage = text @@ -1460,6 +1471,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set, currentTimestamp = entry.timestamp ?? '' currentSessionId = entry.sessionId ?? '' currentBranch = entryBranch + currentPrRefs = [] } } else if (entry.type === 'assistant') { if (entryBranch && !currentBranch) currentBranch = entryBranch @@ -1469,6 +1481,9 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set, const call = parseApiCall(entry, toolResultMeta) if (call) currentCalls.push(call) for (const advisorCall of parseAdvisorCalls(entry)) currentCalls.push(advisorCall) + } else if (entry.type === 'pr-link') { + const url = (entry as Record)['prUrl'] + if (typeof url === 'string' && url && !currentPrRefs.includes(url)) currentPrRefs.push(url) } } @@ -1479,6 +1494,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set, timestamp: currentTimestamp, sessionId: currentSessionId, ...(currentBranch ? { gitBranch: currentBranch } : {}), + ...(currentPrRefs.length > 0 ? { prRefs: [...currentPrRefs].sort() } : {}), }) } @@ -1841,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 @@ -1907,6 +1928,10 @@ async function scanProjectDirs( if (!newTurns[0]!.userMessage.trim() && mergedTurns.length > 0) { const last = mergedTurns[mergedTurns.length - 1]! last.calls = mergeBoundaryCalls(last.calls, newTurns[0]!.calls) + // A PR referenced in the appended continuation belongs to this same + // turn: union its refs in so the shortcut matches a full re-parse. + const refs = Array.from(new Set([...(last.prRefs ?? []), ...(newTurns[0]!.prRefs ?? [])])).sort() + if (refs.length > 0) last.prRefs = refs startIdx = 1 } for (let i = startIdx; i < newTurns.length; i++) mergedTurns.push(newTurns[i]!) @@ -2008,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 } } @@ -2031,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 @@ -2060,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) { @@ -2235,6 +2278,9 @@ function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn { sessionId: turn.sessionId, userMessage: turn.userMessage.slice(0, 2000), calls: turn.assistantCalls.map(apiCallToCachedCall), + // Stored per-turn directly (already sorted/deduped in groupIntoTurns), unlike + // gitBranch's change-detection dedup, so each turn's refs are self-contained. + ...(turn.prRefs?.length ? { prRefs: turn.prRefs } : {}), } } @@ -2344,6 +2390,7 @@ function cachedTurnToClassified(turn: CachedTurn, resolvedBranch?: string): Clas timestamp: turn.timestamp, sessionId: turn.sessionId, ...(branch ? { gitBranch: branch } : {}), + ...(turn.prRefs?.length ? { prRefs: turn.prRefs } : {}), } return classifyTurn(parsed) } diff --git a/src/session-cache.ts b/src/session-cache.ts index 99bb31d..ae068f7 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -61,6 +61,10 @@ export type CachedTurn = { // previous turn's branch (a report carries the last stored value forward). // Rich-session-capture; optional, Claude only. gitBranch?: string + // Claude: GitHub PR URLs referenced during this turn, sorted and deduplicated. + // Stored per-turn directly (unlike gitBranch, no change-detection), so a turn's + // own refs are self-contained. Drives turn-level PR spend attribution. Optional. + prRefs?: string[] } export type FileFingerprint = { @@ -123,7 +127,10 @@ export type SessionCache = { // Cached kiro entries from v4 carry costUSD: undefined and would keep being // re-priced from estimated tokens forever, since historical session files // never change. Bump forces a one-time re-parse so metered credit costs land. -export const CACHE_VERSION = 5 +// v6: per-turn `prRefs` capture for turn-level PR spend attribution. Existing +// cache turns carry no prRefs; bumping forces a one-time re-parse so surviving +// transcripts populate the field. (Daily-cache versioning is untouched.) +export const CACHE_VERSION = 6 // The cache filename is version-suffixed so different binaries (e.g. an old // launchd menubar on a prior release and a newer desktop app) each own a @@ -321,6 +328,7 @@ function validateTurn(t: unknown): t is CachedTurn { && typeof o['sessionId'] === 'string' && typeof o['userMessage'] === 'string' && isOptionalString(o['gitBranch']) + && (o['prRefs'] === undefined || isStringArray(o['prRefs'])) && Array.isArray(o['calls']) && (o['calls'] as unknown[]).every(validateCall) } @@ -336,6 +344,8 @@ function validateCachedFile(f: unknown): f is CachedFile { && isOptionalString(o['title']) && (o['prLinks'] === undefined || isStringArray(o['prLinks'])) && isOptionalBool(o['isSidechain']) + && isOptionalString(o['agentType']) + && isOptionalBool(o['failed']) && Array.isArray(o['turns']) && (o['turns'] as unknown[]).every(validateTurn) } @@ -356,21 +366,83 @@ 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' + +// Lightweight top-level check: a version-5 cache with a providers object. The +// individual files are validated per-entry in adoptV5Cache so one corrupt entry +// cannot drop every valid expired-transcript PR session along with it. +function isV5CacheEnvelope(raw: unknown): raw is { version: number; providers: Record } { + if (!raw || typeof raw !== 'object') return false + const o = raw as Record + return o['version'] === 5 + && !!o['providers'] && typeof o['providers'] === 'object' && !Array.isArray(o['providers']) +} + +// 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 file is validated individually, so a single corrupt +// entry is skipped rather than discarding the whole cache. Each carried section +// takes the CURRENT envFingerprint so the scan reuses it and appends the +// freshly-parsed present sources. The daily cache (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 (!isV5CacheEnvelope(parsed)) return null + const migrated: SessionCache = { version: CACHE_VERSION, providers: {}, complete: false } + for (const [provider, section] of Object.entries(parsed.providers)) { + if (!section || typeof section !== 'object') continue + const rawFiles = (section as Record)['files'] + const files: Record = {} + if (rawFiles && typeof rawFiles === 'object' && !Array.isArray(rawFiles)) { + for (const [path, file] of Object.entries(rawFiles as Record)) { + if (!validateCachedFile(file)) continue + if (!existsSync(path) && file.prLinks?.length) files[path] = file + } + } + migrated.providers[provider] = { + envFingerprint: computeEnvFingerprint(provider), + files, + ...((section as Record)['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 bba367f..90f707f 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 @@ -95,6 +97,17 @@ export type PrRow = { calls: number firstStarted: string lastEnded: string + /// True when any contributing session used the legacy even-split fallback + /// (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+)/ @@ -104,47 +117,231 @@ export function shortenPrUrl(url: string): string { return m ? `${m[1]}/${m[2]}#${m[3]}` : url } -/// Spend attributed to each pull request a session's transcript referenced. -/// A session that mentions two PRs counts fully toward BOTH rows: attribution -/// is by reference, not a split, so rows must never be summed into a grand -/// total (the caller reports distinct-session spend separately). +/// 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 +/// specific PR (turns before the session's first PR reference). +export type SessionPrAttribution = { + perUrl: Map + unattributed: { cost: number; calls: number; savingsUSD: number } +} + +// 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[]; 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 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 (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, 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) || !!session.prRefsAtRangeStart?.length + if (!hasTurnRefs) { + const links = session.prLinks + if (links?.length) { + 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 = 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) + const calls = turn.assistantCalls.length + const savings = turn.assistantCalls.reduce((s, c) => s + (c.savingsUSD ?? 0), 0) + if (cost === 0 && calls === 0 && savings === 0) continue + if (current === null) { + unattributed.cost += cost + unattributed.calls += calls + 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 + 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 } +} + +/// 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; `models` and +/// `categories` are the attributed model/category breakdowns. Sorted by cost, desc. export function aggregateByPr(projects: ProjectSummary[]): PrRow[] { - const byUrl = new Map() + 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 - for (const url of session.prLinks) { + // 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) ?? { - url, label: shortenPrUrl(url), - cost: 0, savingsUSD: 0, sessions: 0, calls: 0, - firstStarted: session.firstTimestamp, lastEnded: session.lastTimestamp, + cost: 0, savingsUSD: 0, calls: 0, approx: false, legacyCost: 0, + sessions: new Set(), firstStarted: session.firstTimestamp, lastEnded: session.lastTimestamp, + models: new Map(), categories: new Map(), } - row.cost += session.totalCostUSD - row.savingsUSD += session.totalSavingsUSD - row.sessions += 1 - row.calls += session.apiCalls + row.cost += c.cost + row.savingsUSD += c.savingsUSD + row.calls += c.calls + row.sessions.add(sessionKey) + // A legacy (approx) contribution carries no per-turn categories; track its + // cost so a mixed row can reconcile its category breakdown to the total. + if (c.approx) { row.approx = true; row.legacyCost += c.cost } + 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) } } } - return [...byUrl.values()].sort((a, b) => b.cost - a.cost) + return [...byUrl.entries()] + .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 (name asc breaks + // ties for a stable order) and cap at the top 4 to bound the payload. + 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] || a[0].localeCompare(b[0])) + .slice(0, 4) + .map(([name]) => name) + const categories = [...r.categories.entries()] + .map(([cat, cost]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, cost })) + // Mixed row: live per-turn categories exist AND part of the row came from a + // legacy even-split (no turn data). Add a synthetic line for the legacy + // share so the expansion reconciles with the row cost instead of silently + // dropping it. A legacy-only row keeps no categories (it surfaces as "no + // per-turn detail"), so there is nothing to reconcile there. + if (categories.length > 0 && r.legacyCost > 0) { + categories.push({ name: 'Legacy estimate (no per-turn detail)', cost: r.legacyCost }) + } + categories.sort((a, b) => b.cost - a.cost || a.name.localeCompare(b.name)) + 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) } -/// Distinct-session totals across every PR-linked session, safe to present as -/// "spend that produced PRs" without the multi-link double count. -export function prLinkedTotals(projects: ProjectSummary[]): { cost: number; sessions: number } { - let cost = 0 +/// Totals across every PR-linked session. `attributedCost` is the sum of the +/// per-PR rows (a summable total, unlike the old by-reference rows); +/// `unattributedCost` is the pre-reference overhead not tied to any specific PR. +/// `cost` = attributed + unattributed = the distinct-session spend that produced +/// PRs. `sessions` counts distinct PR-linked sessions. +export function prLinkedTotals(projects: ProjectSummary[]): { cost: number; sessions: number; attributedCost: number; unattributedCost: number } { + let attributedCost = 0 + let unattributedCost = 0 let sessions = 0 for (const project of projects) { for (const session of project.sessions) { if (!session.prLinks?.length) continue - cost += session.totalCostUSD sessions += 1 + const { perUrl, unattributed } = attributeSessionPrSpend(session) + for (const c of perUrl.values()) attributedCost += c.cost + unattributedCost += unattributed.cost } } - return { cost, sessions } + return { cost: attributedCost + unattributedCost, sessions, attributedCost, unattributedCost } } export type BranchRow = { diff --git a/src/types.ts b/src/types.ts index 248289c..609c600 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,6 +94,11 @@ export type ParsedTurn = { // the turn's entries). Captured for cost-per-branch reporting; deduped at the // cache boundary (stored per-turn only when it changes). Optional; Claude only. gitBranch?: string + // Claude Code: GitHub PR URLs referenced during this turn (`pr-link` entries + // that landed inside the turn's span), sorted and deduplicated. Drives + // turn-level PR spend attribution; the session-level `prLinks` union is built + // separately. Absent when the turn referenced no PR. Optional; Claude only. + prRefs?: string[] } export type ParsedApiCall = { @@ -202,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 c728b72..4e70848 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -666,10 +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-compact-entry.test.ts b/tests/parser-compact-entry.test.ts index 6777d94..5eb30dd 100644 --- a/tests/parser-compact-entry.test.ts +++ b/tests/parser-compact-entry.test.ts @@ -28,6 +28,19 @@ describe('compactEntry', () => { expect((c as Record)['someHugeField']).toBeUndefined() }) + it('preserves prUrl on a pr-link entry for per-turn PR attribution', () => { + const raw = entry({ type: 'pr-link', prUrl: 'https://github.com/o/r/pull/1', extra: 'x'.repeat(10_000) }) + const c = compactEntry(raw) + expect((c as Record)['prUrl']).toBe('https://github.com/o/r/pull/1') + expect((c as Record)['extra']).toBeUndefined() + }) + + it('does not copy prUrl onto non-pr-link entries', () => { + const raw = entry({ type: 'user', prUrl: 'https://github.com/o/r/pull/1' }) + const c = compactEntry(raw) + expect((c as Record)['prUrl']).toBeUndefined() + }) + it('preserves deferred_tools_delta attachment with copied names', () => { const raw = entry({ type: 'attachment', 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/parser-rich-capture.test.ts b/tests/parser-rich-capture.test.ts index 2eaa23c..1422e61 100644 --- a/tests/parser-rich-capture.test.ts +++ b/tests/parser-rich-capture.test.ts @@ -183,6 +183,43 @@ describe('gitBranch capture + dedup', () => { }) }) +// ── per-turn PR references (prRefs) ──────────────────────────────────── + +function prLink(url: string, sessionId = 's1'): JournalEntry { + return { type: 'pr-link', timestamp: '2026-07-01T10:00:03Z', sessionId, prUrl: url } as JournalEntry +} + +describe('pr-link capture + per-turn prRefs', () => { + it('attaches a PR referenced during a turn to that turn, and it survives caching', () => { + const entries = [ + userText('open a PR', 'main'), assistant('m1', 'main'), + prLink('https://github.com/o/r/pull/1'), + userText('next task', 'main'), assistant('m2', 'main'), + ] + const turns = groupIntoTurns(entries, new Set()) + expect(turns[0]!.prRefs).toEqual(['https://github.com/o/r/pull/1']) + expect(turns[1]!.prRefs).toBeUndefined() + const cached = parsedTurnsToCachedTurns(turns) + // Stored per-turn directly (no change-detection dedup like gitBranch). + expect(cached[0]!.prRefs).toEqual(['https://github.com/o/r/pull/1']) + expect(cached[1]!.prRefs).toBeUndefined() + }) + + it('sorts and dedupes multiple refs within one merge-sweep turn', () => { + const entries = [ + userText('merge sweep', 'main'), assistant('m1', 'main'), + prLink('https://github.com/o/r/pull/2'), + prLink('https://github.com/o/r/pull/1'), + prLink('https://github.com/o/r/pull/2'), + ] + const turns = groupIntoTurns(entries, new Set()) + expect(turns[0]!.prRefs).toEqual([ + 'https://github.com/o/r/pull/1', + 'https://github.com/o/r/pull/2', + ]) + }) +}) + // ── session meta: ai-title last-wins, pr-link accumulation ───────────── describe('collectSessionMeta', () => { diff --git a/tests/session-cache-rich-capture.test.ts b/tests/session-cache-rich-capture.test.ts index 7848c77..d824ea9 100644 --- a/tests/session-cache-rich-capture.test.ts +++ b/tests/session-cache-rich-capture.test.ts @@ -52,7 +52,7 @@ function richFile(): CachedFile { prLinks: ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2'], isSidechain: true, turns: [ - { timestamp: '2026-07-01T10:00:00Z', sessionId: 's1', userMessage: 'hi', gitBranch: 'feature/x', calls: [richCall()] }, + { timestamp: '2026-07-01T10:00:00Z', sessionId: 's1', userMessage: 'hi', gitBranch: 'feature/x', prRefs: ['https://github.com/o/r/pull/1'], calls: [richCall()] }, ], } } @@ -73,6 +73,7 @@ describe('session cache round-trip for rich-capture fields', () => { const turn = file.turns[0]! expect(turn.gitBranch).toBe('feature/x') + expect(turn.prRefs).toEqual(['https://github.com/o/r/pull/1']) const call = turn.calls[0]! expect(call.locAdded).toBe(12) diff --git a/tests/session-cache-v5-adoption.test.ts b/tests/session-cache-v5-adoption.test.ts new file mode 100644 index 0000000..b8617a9 --- /dev/null +++ b/tests/session-cache-v5-adoption.test.ts @@ -0,0 +1,185 @@ +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) + }) + + it('skips a corrupt v5 entry but still adopts a valid expired PR entry', async () => { + await loadPricing() + const goodPath = join(configDir, 'projects', 'good-proj', 'good.jsonl') + const badPath = join(configDir, 'projects', 'bad-proj', 'bad.jsonl') + const v5 = { + version: 5, + complete: true, + providers: { + claude: { + envFingerprint: 'stale-v5', + files: { + // Corrupt: malformed turns fail validateCachedFile and are skipped. + [badPath]: { + fingerprint: { dev: 9, ino: 9, mtimeMs: 9, sizeBytes: 9 }, + mcpInventory: [], + prLinks: ['https://github.com/o/r/pull/9'], + turns: [{ garbage: true }], + }, + // Valid expired PR entry that must survive alongside the corrupt one. + [goodPath]: { + fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, + mcpInventory: [], + canonicalCwd: '/good/proj', + canonicalProjectName: 'good-proj', + prLinks: ['https://github.com/o/r/pull/1'], + turns: [{ timestamp: '2026-07-20T10:00:00.000Z', sessionId: 'good', userMessage: 'work', calls: [cachedCall('gk1', 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 rows = aggregateByPr(await parseAllSessions(range, 'claude')) + const urls = rows.map(r => r.url) + expect(urls).toContain('https://github.com/o/r/pull/1') // valid entry survives + expect(urls).not.toContain('https://github.com/o/r/pull/9') // corrupt entry skipped, not fatal + }) + + it('skips a v5 entry whose optional agentType or failed field is malformed', async () => { + await loadPricing() + const badTypePath = join(configDir, 'projects', 'bad-type', 'bad-type.jsonl') + const badFailedPath = join(configDir, 'projects', 'bad-failed', 'bad-failed.jsonl') + const goodPath = join(configDir, 'projects', 'good-proj', 'good.jsonl') + const validTurn = { timestamp: '2026-07-20T10:00:00.000Z', sessionId: 's', userMessage: 'work', calls: [cachedCall('vk1', 40)] } + const v5 = { + version: 5, + complete: true, + providers: { + claude: { + envFingerprint: 'stale-v5', + files: { + [badTypePath]: { + fingerprint: { dev: 9, ino: 9, mtimeMs: 9, sizeBytes: 9 }, + mcpInventory: [], + prLinks: ['https://github.com/o/r/pull/7'], + agentType: {}, + turns: [validTurn], + }, + [badFailedPath]: { + fingerprint: { dev: 8, ino: 8, mtimeMs: 8, sizeBytes: 8 }, + mcpInventory: [], + prLinks: ['https://github.com/o/r/pull/8'], + failed: 'yes', + turns: [validTurn], + }, + [goodPath]: { + fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, + mcpInventory: [], + prLinks: ['https://github.com/o/r/pull/1'], + agentType: 'general-purpose', + failed: false, + turns: [validTurn], + }, + }, + }, + }, + } + 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 rows = aggregateByPr(await parseAllSessions(range, 'claude')) + const urls = rows.map(r => r.url) + expect(urls).toContain('https://github.com/o/r/pull/1') // well-formed optionals adopt fine + expect(urls).not.toContain('https://github.com/o/r/pull/7') // agentType: {} is corrupt, skipped + expect(urls).not.toContain('https://github.com/o/r/pull/8') // failed: 'yes' is corrupt, skipped + }) +}) diff --git a/tests/sessions-by-pr.test.ts b/tests/sessions-by-pr.test.ts index b94ac5b..e882ee7 100644 --- a/tests/sessions-by-pr.test.ts +++ b/tests/sessions-by-pr.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from 'vitest' -import { aggregateByPr, prLinkedTotals, shortenPrUrl } from '../src/sessions-report.js' -import type { ProjectSummary, SessionSummary } from '../src/types.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' +const B = 'https://github.com/o/r/pull/2' + +// A legacy session: session-level prLinks but NO per-turn refs (turns: []), so +// the by-PR path takes the even-split fallback. function session(id: string, cost: number, calls: number, prLinks?: string[], first = '2026-07-01T10:00:00Z', last = '2026-07-01T11:00:00Z'): SessionSummary { return { sessionId: id, project: 'p', @@ -19,6 +24,46 @@ function session(id: string, cost: number, calls: number, prLinks?: string[], fi } } +const ZERO_USAGE: TokenUsage = { + inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, +} + +let keySeq = 0 +function call(cost: number): ParsedApiCall { + return { + provider: 'claude', model: 'claude', usage: ZERO_USAGE, costUSD: cost, + tools: [], mcpTools: [], skills: [], subagentTypes: [], + hasAgentSpawn: false, hasPlanMode: false, speed: 'standard', + timestamp: '2026-07-01T10:00:00Z', bashCommands: [], deduplicationKey: `k${keySeq++}`, + } +} + +// A turn whose total cost `cost` is split across `calls` API calls, optionally +// referencing `prRefs` (the PRs touched during the turn). +function cturn(cost: number, calls = 1, prRefs?: string[]): ClassifiedTurn { + return { + userMessage: '', + assistantCalls: Array.from({ length: calls }, () => call(cost / calls)), + timestamp: '2026-07-01T10:00:00Z', sessionId: 's', + category: 'coding', retries: 0, hasEdits: false, + ...(prRefs ? { prRefs } : {}), + } +} + +function sessionWithTurns(id: string, prLinks: string[], turns: ClassifiedTurn[], first = '2026-07-01T10:00:00Z', last = '2026-07-01T11:00:00Z'): SessionSummary { + return { ...session(id, 0, 0, prLinks, first, last), turns } +} + +// A turn referencing `prRefs` whose calls each carry a [model, cost] pair. +function turnModels(prRefs: string[], calls: Array<[string, number]>): ClassifiedTurn { + return { + userMessage: '', timestamp: '2026-07-01T10:00:00Z', sessionId: 's', + category: 'coding', retries: 0, hasEdits: false, prRefs, + assistantCalls: calls.map(([model, cost]) => ({ ...call(cost), model })), + } +} + function project(sessions: SessionSummary[]): ProjectSummary { return { project: 'p', projectPath: '/p', sessions, totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0, totalProxiedCostUSD: 0 } } @@ -30,14 +75,126 @@ describe('shortenPrUrl', () => { }) }) -describe('aggregateByPr', () => { - it('groups sessions by PR, sorted by cost, tracking the date span', () => { +describe('attributeSessionPrSpend (turn-level state machine)', () => { + it('attributes each turn of interleaved A,B,A,B to its own PR', () => { + const { perUrl, unattributed } = attributeSessionPrSpend({ + prLinks: [A, B], totalCostUSD: 40, apiCalls: 4, totalSavingsUSD: 0, + turns: [ + { prRefs: [A], assistantCalls: [{ costUSD: 10 }] }, + { prRefs: [B], assistantCalls: [{ costUSD: 10 }] }, + { prRefs: [A], assistantCalls: [{ costUSD: 10 }] }, + { prRefs: [B], assistantCalls: [{ costUSD: 10 }] }, + ], + }) + expect(perUrl.get(A)!.cost).toBeCloseTo(20, 6) + expect(perUrl.get(B)!.cost).toBeCloseTo(20, 6) + expect(perUrl.get(A)!.approx).toBe(false) + expect(unattributed.cost).toBe(0) + }) + + it('splits a multi-PR merge-sweep turn evenly across its refs (cost and calls)', () => { + const { perUrl } = attributeSessionPrSpend({ + prLinks: [A, B], totalCostUSD: 100, apiCalls: 4, totalSavingsUSD: 0, + turns: [{ prRefs: [A, B], assistantCalls: [{ costUSD: 25 }, { costUSD: 25 }, { costUSD: 25 }, { costUSD: 25 }] }], + }) + expect(perUrl.get(A)!.cost).toBeCloseTo(50, 6) + expect(perUrl.get(B)!.cost).toBeCloseTo(50, 6) + expect(perUrl.get(A)!.calls).toBeCloseTo(2, 6) + expect(perUrl.get(B)!.calls).toBeCloseTo(2, 6) + }) + + it('accumulates pre-first-reference turns into the unattributed bucket', () => { + const { perUrl, unattributed } = attributeSessionPrSpend({ + prLinks: [A], totalCostUSD: 40, apiCalls: 2, totalSavingsUSD: 0, + turns: [ + { assistantCalls: [{ costUSD: 30 }] }, + { prRefs: [A], assistantCalls: [{ costUSD: 10 }] }, + ], + }) + expect(unattributed.cost).toBeCloseTo(30, 6) + expect(perUrl.get(A)!.cost).toBeCloseTo(10, 6) + }) + + it('carries the current PR forward so a single-PR session attributes everything', () => { + const { perUrl, unattributed } = attributeSessionPrSpend({ + prLinks: [A], totalCostUSD: 60, apiCalls: 3, totalSavingsUSD: 0, + turns: [ + { prRefs: [A], assistantCalls: [{ costUSD: 10 }] }, + { assistantCalls: [{ costUSD: 20 }] }, + { assistantCalls: [{ costUSD: 30 }] }, + ], + }) + expect(perUrl.get(A)!.cost).toBeCloseTo(60, 6) + expect(unattributed.cost).toBe(0) + }) + + it('falls back to an even whole-session split (approx) when no turn carries refs', () => { + const { perUrl, unattributed } = attributeSessionPrSpend({ + prLinks: [A, B], totalCostUSD: 100, apiCalls: 8, totalSavingsUSD: 4, + turns: [{ assistantCalls: [{ costUSD: 100, savingsUSD: 4 }] }], + }) + expect(perUrl.get(A)!.cost).toBeCloseTo(50, 6) + expect(perUrl.get(B)!.cost).toBeCloseTo(50, 6) + expect(perUrl.get(A)!.calls).toBeCloseTo(4, 6) + expect(perUrl.get(A)!.approx).toBe(true) + expect(perUrl.get(B)!.approx).toBe(true) + expect(unattributed.cost).toBe(0) + }) + + it('attributed + unattributed equals the sum of turn costs', () => { + const { perUrl, unattributed } = attributeSessionPrSpend({ + prLinks: [A, B], totalCostUSD: 0, apiCalls: 0, totalSavingsUSD: 0, + turns: [ + { assistantCalls: [{ costUSD: 7 }] }, + { prRefs: [A], assistantCalls: [{ costUSD: 13 }] }, + { prRefs: [A, B], assistantCalls: [{ costUSD: 20 }] }, + ], + }) + const attributed = [...perUrl.values()].reduce((s, c) => s + c.cost, 0) + expect(attributed + unattributed.cost).toBeCloseTo(40, 6) + }) +}) + +describe('aggregateByPr (turn-level attribution)', () => { + it('splits a multi-PR session across turns instead of counting the full cost to each', () => { const rows = aggregateByPr([project([ - session('a', 100, 40, ['https://github.com/o/r/pull/1'], '2026-07-01T10:00:00Z', '2026-07-01T11:00:00Z'), - session('b', 50, 10, ['https://github.com/o/r/pull/1'], '2026-06-20T09:00:00Z', '2026-06-20T10:00:00Z'), - session('c', 200, 80, ['https://github.com/o/r/pull/2']), + sessionWithTurns('s', [A, B], [ + cturn(10, 1, [A]), + cturn(30, 1, [B]), + cturn(20, 1), // no refs → carries B forward + ]), ])]) - expect(rows.map(r => r.label)).toEqual(['o/r#2', 'o/r#1']) + const a = rows.find(r => r.url === A)! + const b = rows.find(r => r.url === B)! + expect(a.cost).toBeCloseTo(10, 6) // NOT the full 60 a by-reference count would give + expect(b.cost).toBeCloseTo(50, 6) + expect(a.approx).toBe(false) + expect(a.sessions).toBe(1) + }) + + it('rows are summable: attributed rows + unattributed equal the PR-linked total', () => { + const projects = [project([ + sessionWithTurns('s', [A], [ + cturn(30, 1), // overhead before the first PR reference + cturn(10, 1, [A]), + ]), + ])] + const rows = aggregateByPr(projects) + const totals = prLinkedTotals(projects) + const rowSum = rows.reduce((s, r) => s + r.cost, 0) + expect(rowSum).toBeCloseTo(totals.attributedCost, 6) + expect(totals.attributedCost).toBeCloseTo(10, 6) + expect(totals.unattributedCost).toBeCloseTo(30, 6) + expect(totals.cost).toBeCloseTo(40, 6) + }) + + it('groups legacy sessions by PR, sorted by cost, tracking the date span', () => { + const rows = aggregateByPr([project([ + session('a', 100, 40, [A], '2026-07-01T10:00:00Z', '2026-07-01T11:00:00Z'), + session('b', 50, 10, [A], '2026-06-20T09:00:00Z', '2026-06-20T10:00:00Z'), + session('c', 200, 80, [B]), + ])]) + expect(rows.map(r => r.url)).toEqual([B, A]) const pr1 = rows[1]! expect(pr1.cost).toBe(150) expect(pr1.sessions).toBe(2) @@ -46,13 +203,14 @@ describe('aggregateByPr', () => { expect(pr1.lastEnded).toBe('2026-07-01T11:00:00Z') }) - it('a session referencing several PRs counts fully toward each row', () => { + it('a legacy multi-PR session splits its cost evenly and marks the rows approx', () => { const rows = aggregateByPr([project([ - session('a', 100, 40, ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2']), + session('a', 100, 40, [A, B]), ])]) expect(rows).toHaveLength(2) - expect(rows[0]!.cost).toBe(100) - expect(rows[1]!.cost).toBe(100) + expect(rows[0]!.cost).toBeCloseTo(50, 6) + expect(rows[1]!.cost).toBeCloseTo(50, 6) + expect(rows.every(r => r.approx)).toBe(true) }) it('sessions without links contribute nothing', () => { @@ -60,13 +218,164 @@ describe('aggregateByPr', () => { }) }) -describe('prLinkedTotals', () => { - it('counts each PR-linked session once regardless of how many PRs it references', () => { - const totals = prLinkedTotals([project([ - session('a', 100, 40, ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2']), - session('b', 50, 10, ['https://github.com/o/r/pull/1']), - session('c', 999, 1), - ])]) - expect(totals).toEqual({ cost: 150, sessions: 2 }) +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('mixed live + legacy category reconciliation (round-3 finding 1)', () => { + it('adds a synthetic legacy line so a mixed row reconciles to its cost', () => { + const rows = aggregateByPr([project([ + sessionWithTurns('live', [A], [cturn(10, 1, [A])]), // live: Coding $10 + session('legacy', 90, 5, [A]), // legacy: $90, no turn data + ])]) + const row = rows.find(r => r.url === A)! + expect(row.cost).toBeCloseTo(100, 6) + expect(row.approx).toBe(true) + const cats = row.categories! + expect(cats.reduce((s, c) => s + c.cost, 0)).toBeCloseTo(row.cost, 6) // reconciles + expect(cats.find(c => c.name === 'Coding')!.cost).toBeCloseTo(10, 6) + expect(cats.find(c => c.name === 'Legacy estimate (no per-turn detail)')!.cost).toBeCloseTo(90, 6) + }) + + it('a legacy-only row still omits categories (surfaces the no-detail note)', () => { + const rows = aggregateByPr([project([session('legacy', 90, 5, [A])])]) + expect(rows[0]!.categories).toBeUndefined() + }) +}) + +describe('model list bounds (round-3 finding 5)', () => { + it('caps a row to the top 4 models by attributed cost', () => { + const rows = aggregateByPr([project([ + sessionWithTurns('s', [A], [turnModels([A], [['m-f', 60], ['m-e', 50], ['m-d', 40], ['m-c', 30], ['m-b', 20], ['m-a', 10]])]), + ])]) + expect(rows[0]!.models).toEqual(['m-f', 'm-e', 'm-d', 'm-c']) + }) + + it('breaks model ties by name ascending for a stable order', () => { + const rows = aggregateByPr([project([ + sessionWithTurns('s', [A], [turnModels([A], [['m-d', 10], ['m-a', 10], ['m-c', 10], ['m-b', 10]])]), + ])]) + expect(rows[0]!.models).toEqual(['m-a', 'm-b', 'm-c', 'm-d']) + }) +}) + +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([ + session('a', 100, 40, [A, B]), // legacy: 50 + 50 attributed + session('b', 50, 10, [A]), // legacy: 50 attributed + session('c', 999, 1), // no links → excluded + ])]) + expect(totals).toEqual({ cost: 150, sessions: 2, attributedCost: 150, unattributedCost: 0 }) + }) + + it('captures the unattributed remainder from pre-reference overhead', () => { + const totals = prLinkedTotals([project([ + sessionWithTurns('s', [A], [cturn(30, 1), cturn(10, 1, [A])]), + ])]) + expect(totals.attributedCost).toBeCloseTo(10, 6) + expect(totals.unattributedCost).toBeCloseTo(30, 6) + expect(totals.cost).toBeCloseTo(40, 6) + expect(totals.sessions).toBe(1) }) })