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