sessions: attribute PR spend per turn instead of per session (#790)

* sessions: attribute PR spend per turn instead of per session

The by-PR surfaces attributed a session's full cost to every PR it
referenced, so one orchestration session that touched many PRs rendered
identical full-session rows. Capture per-turn PR references during
transcript parsing and attribute each turn's cost to the PR set active at
that turn (split evenly across a multi-PR merge-sweep turn), carrying the
most recent PR set forward across turns that reference nothing. Turns
before the first reference form an unattributed bucket.

A session whose transcript expired before per-turn capture keeps its
session-level prLinks but has no per-turn refs; it falls back to an even
whole-session split, and any row carrying such a portion is flagged
approximate.

Rows are now summable: the CLI and app footers report the attributed sum
plus the unattributed remainder instead of a distinct-session total.
Bumps the session cache version so surviving transcripts re-parse and
populate the new per-turn field. Daily-cache versioning is untouched.

* 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.

* sessions: reconcile mixed PR rows, harden v5 adoption, bound models

Round-2 review follow-ups.

- Mixed legacy/live category breakdown: a PR row that combines an expired
  legacy contribution (even-split, no turn data) with a live per-turn
  contribution now emits an explicit "Legacy estimate (no per-turn detail)"
  category carrying the legacy share, so the expansion reconciles to the row
  cost instead of silently dropping it. A legacy-only row still shows no
  breakdown.
- v5 adoption is now per cached file: one malformed entry is skipped instead
  of rejecting the whole v5 cache and dropping every valid expired PR session.
- The desktop "Other (N more PRs)" line moved to the table footer as a muted,
  separated summary rather than a sorted row.
- Row expansion resets when the PR set changes (period/provider/data), so a
  stale expansion cannot linger on a row that is gone.
- Payload models per row are capped to the top 4 by attributed cost, with a
  name-ascending tie-break; categories get the same stable tie-break.

Tests: mixed live+legacy reconciliation, legacy-only omission, model cap and
tie-break, corrupt-plus-valid v5 adoption, and the desktop expansion-reset.

* session-cache: validate optional agentType and failed during v5 adoption; app: reset PR expansion on period switch

A v5 entry that was valid except for a malformed optional field (agentType,
failed) passed per-file validation and flowed downstream as a non-string.
The expansion reset keyed only on the row URL set, so a period switch that
returned the same PRs kept a stale expansion open over changed numbers.

---------

Co-authored-by: reviewer <review@local>
This commit is contained in:
Resham Joshi 2026-07-20 20:44:29 -07:00 committed by GitHub
parent a9d4471d17
commit 45e93129f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1402 additions and 106 deletions

View file

@ -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: {

View file

@ -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(<PullRequests period="lifetime" provider="all" />)
// 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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
await userEvent.click(rowForLink(link))
expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true')
rerender(<PullRequests period="week" provider="all" />)
// 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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="week" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()

View file

@ -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<HTMLAnchorElement>, 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<HTMLTableRowElement>, 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 <PullRequestsContent overview={overview} />
// 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 <PullRequestsContent key={`${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`} overview={overview} />
}
export function PullRequestsContent({ overview }: { overview: Polled<MenubarPayload> }) {
@ -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<string | null>(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 (
<>
<div className="ov-model-scroll">
<div className="pr-scroll">
<table className="ov-models pr-table" aria-label="Spend by pull request">
<thead>
<tr>
<th>Pull request</th>
<th className="pr-models">Models</th>
<th className="num">Cost</th>
<th className="num">Sessions</th>
<th className="num">Calls</th>
<th className="num">Active</th>
<th className="pr-chevron-cell" aria-hidden="true"></th>
</tr>
</thead>
<tbody>
{rows.map(pr => <PrRowView key={pr.url} pr={pr} />)}
{rows.map(pr => (
<PrRowView
key={pr.url}
pr={pr}
expanded={expandedUrl === pr.url}
onToggle={() => setExpandedUrl(current => current === pr.url ? null : pr.url)}
/>
))}
</tbody>
{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.
<tfoot>
<tr className="pr-other-row">
<td className="pr-other-label">Other ({otherCount.toLocaleString('en-US')} more PRs)</td>
<td className="pr-models"></td>
<td className="num mono">{formatUsd(otherCost)}</td>
<td className="num"></td>
<td className="num"></td>
<td className="num pr-span"></td>
<td className="pr-chevron-cell"></td>
</tr>
</tfoot>
)}
</table>
</div>
<p className="pr-footnote">
{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.
</p>
{summable ? (
<p className="pr-footnote">
{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.
</p>
) : (
<p className="pr-footnote">
{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.
</p>
)}
{unattributed > 0 && (
<p className="pr-unattributed">Not tied to a specific PR: {formatUsd(unattributed)}</p>
)}
</>
)
}
function PrRowView({ pr }: { pr: PrRow }) {
const APPROX_TITLE = 'Approximate: the transcript expired before per-turn capture, so this PRs 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 (
<tr>
<td className="ov-model-name">
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
</td>
<td className="num mono">{formatUsd(pr.cost)}</td>
<td className="num">{pr.sessions.toLocaleString('en-US')}</td>
<td className="num">{pr.calls.toLocaleString('en-US')}</td>
<td className="num pr-span">{spanLabel(pr.firstStarted, pr.lastEnded)}</td>
</tr>
<>
<tr
className="pr-row"
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={onToggle}
onKeyDown={event => rowKeyDown(event, onToggle)}
>
<td className="ov-model-name">
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
</td>
<td className="pr-models">{models.length ? modelsLabel(models) : ''}</td>
<td className="num mono" {...(pr.approx ? { title: APPROX_TITLE } : {})}>
{pr.approx ? '~' : ''}{formatUsd(pr.cost)}
</td>
<td className="num">{pr.sessions.toLocaleString('en-US')}</td>
<td className="num">{pr.calls.toLocaleString('en-US')}</td>
<td className="num pr-span">{spanLabel(pr.firstStarted, pr.lastEnded)}</td>
<td className="pr-chevron-cell"><span className="pr-chevron" aria-hidden="true"></span></td>
</tr>
{expanded && (
<tr className="pr-detail-row">
<td className="pr-detail-cell" colSpan={7}>
{categories.length > 0 ? (
<div className="pr-cats" role="region" aria-label={`${pr.label} cost breakdown`}>
{categories.map(cat => (
<div className="pr-cat" key={cat.name}>
<div className="pr-cat-bar" aria-hidden="true">
<span style={{ width: `${catMax > 0 ? cat.cost / catMax * 100 : 0}%` }} />
</div>
<div className="pr-cat-main">
<span className="pr-cat-name">{cat.name}</span>
<strong>{formatUsd(cat.cost)}</strong>
</div>
</div>
))}
</div>
) : (
<p className="pr-cat-empty">No per-turn detail (estimated from a whole-session split).</p>
)}
</td>
</tr>
)}
</>
)
}

View file

@ -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; }

View file

@ -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)

View file

@ -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 = {

View file

@ -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<string, unknown>)['prUrl']
if (typeof prUrl === 'string' && prUrl) (entry as Record<string, unknown>)['prUrl'] = prUrl
}
const att = (raw as Record<string, unknown>)['attachment']
if (att && typeof att === 'object') {
@ -1440,6 +1446,10 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
// 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<string>,
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<string>,
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<string>,
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<string, unknown>)['prUrl']
if (typeof url === 'string' && url && !currentPrRefs.includes(url)) currentPrRefs.push(url)
}
}
@ -1479,6 +1494,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
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)
}

View file

@ -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<string, unknown>).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<string, unknown> } {
if (!raw || typeof raw !== 'object') return false
const o = raw as Record<string, unknown>
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<SessionCache | null> {
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<string, unknown>)['files']
const files: Record<string, CachedFile> = {}
if (rawFiles && typeof rawFiles === 'object' && !Array.isArray(rawFiles)) {
for (const [path, file] of Object.entries(rawFiles as Record<string, unknown>)) {
if (!validateCachedFile(file)) continue
if (!existsSync(path) && file.prLinks?.length) files[path] = file
}
}
migrated.providers[provider] = {
envFingerprint: computeEnvFingerprint(provider),
files,
...((section as Record<string, unknown>)['durable'] ? { durable: true } : {}),
}
}
return migrated
} catch {
return null
}
}
export async function loadCache(): Promise<SessionCache> {
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<SessionCache> {
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<SessionCache> {
try {
const raw = await readFile(getLegacyCachePath(), 'utf-8')

View file

@ -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<string, number>
categories: Map<string, number>
}
/// 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<string, PrContribution>
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<string, number>, key: string, value: number): void {
m.set(key, (m.get(key) ?? 0) + value)
}
function ensureContribution(map: Map<string, PrContribution>, 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<string, PrContribution>()
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<string, number>()
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<string, number>()
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<string, PrRow>()
const byUrl = new Map<string, {
cost: number; savingsUSD: number; calls: number; approx: boolean
legacyCost: number
sessions: Set<string>; firstStarted: string; lastEnded: string
models: Map<string, number>; categories: Map<string, number>
}>()
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<string>(), firstStarted: session.firstTimestamp, lastEnded: session.lastTimestamp,
models: new Map<string, number>(), categories: new Map<string, number>(),
}
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<string, number>()
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 = {

View file

@ -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

View file

@ -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)

View file

@ -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',

View file

@ -28,6 +28,19 @@ describe('compactEntry', () => {
expect((c as Record<string, unknown>)['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<string, unknown>)['prUrl']).toBe('https://github.com/o/r/pull/1')
expect((c as Record<string, unknown>)['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<string, unknown>)['prUrl']).toBeUndefined()
})
it('preserves deferred_tools_delta attachment with copied names', () => {
const raw = entry({
type: 'attachment',

View file

@ -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-'))

View file

@ -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', () => {

View file

@ -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)

View file

@ -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<string, unknown> {
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
})
})

View file

@ -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)
})
})