mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-06 23:24:32 +00:00
sessions: correlate cross-provider work with pull requests
This commit is contained in:
parent
8e1caae33a
commit
b7cf5f2f95
16 changed files with 511 additions and 192 deletions
|
|
@ -220,7 +220,7 @@ 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), attributed at turn
|
||||
// Spend by referenced pull request (every PR, cost-descending), 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
|
||||
|
|
@ -250,8 +250,6 @@ export type MenubarPayload = {
|
|||
subagentSessions?: number
|
||||
attributedCost?: number
|
||||
unattributedCost?: number
|
||||
otherPrCount?: number
|
||||
otherPrCost?: number
|
||||
}
|
||||
}
|
||||
optimize: {
|
||||
|
|
|
|||
|
|
@ -53,8 +53,6 @@ const SAMPLE: PrPayload = {
|
|||
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.
|
||||
|
|
@ -71,26 +69,27 @@ describe('PullRequests', () => {
|
|||
openExternal.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('renders PR rows as a table with linked labels, cost, and a date span', async () => {
|
||||
it('renders PR cards with linked labels, cost, activity, and a date span', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/getagentseal/codeburn/pull/780')
|
||||
expect(screen.getByText('$240.50')).toBeInTheDocument()
|
||||
expect(screen.getByText('512')).toBeInTheDocument()
|
||||
expect(screen.getByText('512 calls')).toBeInTheDocument()
|
||||
expect(screen.getByText(expectedSpan(SAMPLE.rows[0]!.firstStarted, SAMPLE.rows[0]!.lastEnded))).toBeInTheDocument()
|
||||
// A same-day PR collapses its span to a single label.
|
||||
expect(screen.getByText(expectedSpan(SAMPLE.rows[1]!.firstStarted, SAMPLE.rows[1]!.lastEnded))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the Models column with a "+N" overflow tag', async () => {
|
||||
it('renders every model explicitly instead of hiding models behind an overflow count', 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(await screen.findByText('fable')).toBeInTheDocument()
|
||||
expect(screen.getByText('opus')).toBeInTheDocument()
|
||||
expect(screen.getByText('haiku')).toBeInTheDocument()
|
||||
expect(screen.queryByText('+1')).toBeNull()
|
||||
expect(screen.getByText('sonnet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
@ -178,10 +177,11 @@ describe('PullRequests', () => {
|
|||
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(await screen.findByText('Attributed spend')).toBeInTheDocument()
|
||||
expect(screen.getByText('$330.75')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Pull request attribution summary')).toHaveTextContent('Linked sessions3')
|
||||
const note = screen.getByText(/attributed turn by turn/)
|
||||
expect(note.textContent).toContain('without double counting')
|
||||
expect(screen.getByText(/Not tied to a specific PR/).textContent).toContain('$45.30')
|
||||
})
|
||||
|
||||
|
|
@ -189,16 +189,17 @@ describe('PullRequests', () => {
|
|||
getOverview.mockResolvedValue(makePayload({ ...SAMPLE, subagentSessions: 32 }))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const note = await screen.findByText(/attributed to the rows above/)
|
||||
expect(note.textContent).toContain('3 PR-linked sessions')
|
||||
expect(note.textContent).toContain('32 folded-in subagent runs')
|
||||
expect(await screen.findByText('Folded agent runs')).toBeInTheDocument()
|
||||
expect(screen.getByText('32')).toBeInTheDocument()
|
||||
const note = screen.getByText(/attributed turn by turn/)
|
||||
expect(note.textContent).toContain('32 subagent runs are included')
|
||||
})
|
||||
|
||||
it('omits the subagent note when none were folded', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const note = await screen.findByText(/attributed to the rows above/)
|
||||
const note = await screen.findByText(/attributed turn by turn/)
|
||||
expect(note.textContent).not.toContain('subagent')
|
||||
})
|
||||
|
||||
|
|
@ -260,23 +261,23 @@ describe('PullRequests', () => {
|
|||
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))
|
||||
it('renders the complete PR list without an opaque Other row', async () => {
|
||||
const manyRows = Array.from({ length: 32 }, (_, index) => ({
|
||||
...SAMPLE.rows[0]!,
|
||||
url: `https://github.com/getagentseal/codeburn/pull/${800 + index}`,
|
||||
label: `getagentseal/codeburn#${800 + index}`,
|
||||
}))
|
||||
getOverview.mockResolvedValue(makePayload({
|
||||
...SAMPLE,
|
||||
rows: manyRows,
|
||||
attributedCost: manyRows.reduce((sum, row) => sum + row.cost, 0),
|
||||
}))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
expect(await screen.findByText('Other (5 more PRs)')).toBeInTheDocument()
|
||||
expect(screen.getByText('$88.40')).toBeInTheDocument()
|
||||
expect(await screen.findByText('getagentseal/codeburn#800')).toBeInTheDocument()
|
||||
expect(screen.getByText('getagentseal/codeburn#831')).toBeInTheDocument()
|
||||
expect(screen.getByText('32 total')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Other \(/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the quiet empty state (never a fake table) when no PR links exist', async () => {
|
||||
|
|
|
|||
|
|
@ -27,10 +27,12 @@ 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 ModelChips({ models }: { models: string[] }) {
|
||||
return (
|
||||
<div className="pr-model-list" aria-label={models.length ? `Models used: ${models.join(', ')}` : 'No model data'}>
|
||||
{models.map(model => <span className="pr-model-chip" key={model}>{model}</span>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function openPr(event: MouseEvent<HTMLAnchorElement>, url: string): void {
|
||||
|
|
@ -41,7 +43,7 @@ function openPr(event: MouseEvent<HTMLAnchorElement>, url: string): void {
|
|||
|
||||
// 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 {
|
||||
function rowKeyDown(event: KeyboardEvent<HTMLDivElement>, toggle: () => void): void {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
|
|
@ -73,7 +75,7 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq
|
|||
return (
|
||||
<>
|
||||
{staleError && <StaleBanner error={staleError} />}
|
||||
<Panel title="Spend by pull request">
|
||||
<Panel title="Pull request spend">
|
||||
{pullRequests && pullRequests.rows.length > 0
|
||||
? <PrTable pullRequests={pullRequests} />
|
||||
: <EmptyNote>PR links are captured as sessions are parsed. Once a session references a pull request, it appears here.</EmptyNote>}
|
||||
|
|
@ -83,7 +85,7 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq
|
|||
}
|
||||
|
||||
function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
|
||||
const { rows, distinctCost, distinctSessions, subagentSessions, attributedCost, unattributedCost, otherPrCount, otherPrCost } = pullRequests
|
||||
const { rows, distinctCost, distinctSessions, subagentSessions, attributedCost, unattributedCost } = 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
|
||||
|
|
@ -95,59 +97,51 @@ function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
|
|||
// 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
|
||||
// Reconcile to the visible numbers: every PR is present, so the summary is
|
||||
// exactly the sum of the rounded cards a person can inspect below.
|
||||
const displayedAttributed = rows.reduce((sum, row) => sum + Number(row.cost.toFixed(2)), 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
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 className="pr-summary" aria-label="Pull request attribution summary">
|
||||
<div className="pr-summary-item">
|
||||
<span>Attributed spend</span>
|
||||
<strong>{formatUsd(summable ? displayedAttributed : distinctCost)}</strong>
|
||||
</div>
|
||||
<div className="pr-summary-item">
|
||||
<span>Pull requests</span>
|
||||
<strong>{rows.length.toLocaleString('en-US')}</strong>
|
||||
</div>
|
||||
<div className="pr-summary-item">
|
||||
<span>Linked sessions</span>
|
||||
<strong>{distinctSessions.toLocaleString('en-US')}</strong>
|
||||
</div>
|
||||
<div className="pr-summary-item">
|
||||
<span>Folded agent runs</span>
|
||||
<strong>{(subagentSessions ?? 0).toLocaleString('en-US')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pr-list-head">
|
||||
<div>
|
||||
<strong>Attributed pull requests</strong>
|
||||
<span>Sorted by spend, highest first</span>
|
||||
</div>
|
||||
<span className="pr-list-count">{rows.length.toLocaleString('en-US')} total</span>
|
||||
</div>
|
||||
<div className="pr-list" aria-label="Spend by pull request">
|
||||
{rows.map(pr => (
|
||||
<PrRowView
|
||||
key={pr.url}
|
||||
pr={pr}
|
||||
expanded={expandedUrl === pr.url}
|
||||
onToggle={() => setExpandedUrl(current => current === pr.url ? null : pr.url)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{summable ? (
|
||||
<p className="pr-footnote">
|
||||
{formatUsd(displayedAttributed)} attributed to the rows above, across {distinctSessions.toLocaleString('en-US')} PR-linked {sessionWord(distinctSessions)}
|
||||
{subagentSessions ? ` + ${subagentSessions.toLocaleString('en-US')} folded-in subagent ${subagentSessions === 1 ? 'run' : 'runs'}` : ''}.
|
||||
{' '}Each turn's cost goes to the PR it was working on, so the rows are summable.
|
||||
Costs are attributed turn by turn, so every row adds up without double counting.
|
||||
{subagentSessions ? ` ${subagentSessions.toLocaleString('en-US')} subagent ${subagentSessions === 1 ? 'run is' : 'runs are'} included in the PR where the work happened.` : ''}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pr-footnote">
|
||||
|
|
@ -170,50 +164,63 @@ function PrRowView({ pr, expanded, onToggle }: { pr: PrRow; expanded: boolean; o
|
|||
const catMax = categories.length ? Math.max(...categories.map(cat => cat.cost)) : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="pr-row"
|
||||
<article className={expanded ? 'pr-card is-open' : 'pr-card'}>
|
||||
<div
|
||||
className="pr-card-trigger"
|
||||
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>
|
||||
<div className="pr-card-identity">
|
||||
<span className="pr-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><circle cx="6" cy="5" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M6 7.5V19M11 5h4a3 3 0 0 1 3 3v8.5"/></svg>
|
||||
</span>
|
||||
<div>
|
||||
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
|
||||
<div className="pr-card-meta">
|
||||
<span>{spanLabel(pr.firstStarted, pr.lastEnded)}</span>
|
||||
<span>{pr.sessions.toLocaleString('en-US')} {sessionWord(pr.sessions)}</span>
|
||||
<span>{pr.calls.toLocaleString('en-US')} calls</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pr-card-models">
|
||||
<span className="pr-card-label">Models</span>
|
||||
<ModelChips models={models} />
|
||||
</div>
|
||||
<div className="pr-card-cost">
|
||||
<span className="pr-card-label">Spend</span>
|
||||
<strong {...(pr.approx ? { title: APPROX_TITLE } : {})}>{pr.approx ? '~' : ''}{formatUsd(pr.cost)}</strong>
|
||||
</div>
|
||||
<span className="pr-chevron" aria-hidden="true">›</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<tr className="pr-detail-row">
|
||||
<td className="pr-detail-cell" colSpan={7}>
|
||||
<div className="pr-detail-cell">
|
||||
{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">
|
||||
<div className="pr-detail" role="region" aria-label={`${pr.label} cost breakdown`}>
|
||||
<div className="pr-detail-head">
|
||||
<span>Work breakdown</span>
|
||||
<strong>{formatUsd(pr.cost)} total</strong>
|
||||
</div>
|
||||
<div className="pr-cats">
|
||||
{categories.map(cat => (
|
||||
<div className="pr-cat" key={cat.name}>
|
||||
<span className="pr-cat-name">{cat.name}</span>
|
||||
<div className="pr-cat-bar" aria-hidden="true">
|
||||
<span style={{ width: `${catMax > 0 ? cat.cost / catMax * 100 : 0}%` }} />
|
||||
</div>
|
||||
<strong>{formatUsd(cat.cost)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="pr-cat-empty">No per-turn detail (estimated from a whole-session split).</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -580,31 +580,57 @@ 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-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 20px; }
|
||||
.pr-summary-item { display: flex; min-width: 0; flex-direction: column; gap: 7px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 9px; background: color-mix(in srgb, var(--panel) 92%, var(--fill)); box-shadow: 0 1px 2px rgba(0,0,0,.08), inset 0 1px 0 rgba(255,255,255,.025); }
|
||||
.pr-summary-item span { color: var(--mut2); font-size: var(--fs-label); font-weight: var(--fw-strong); letter-spacing: .055em; text-transform: uppercase; }
|
||||
.pr-summary-item strong { overflow: hidden; color: var(--ink); font-family: var(--mono); font-size: 20px; font-weight: var(--fw-kpi); font-variant-numeric: tabular-nums; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pr-list-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 0 2px 10px; }
|
||||
.pr-list-head > div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.pr-list-head strong { color: var(--ink); font-size: var(--fs-body); font-weight: var(--fw-subhead); }
|
||||
.pr-list-head span { color: var(--mut2); font-size: var(--fs-meta); }
|
||||
.pr-list-count { padding: 4px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--fill); font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.pr-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.pr-card { overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 1px 2px rgba(0,0,0,.07); transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; }
|
||||
.pr-card:hover { border-color: color-mix(in srgb, var(--line) 55%, var(--mut2)); box-shadow: 0 4px 12px rgba(0,0,0,.10); transform: translateY(-1px); }
|
||||
.pr-card.is-open { border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); box-shadow: 0 6px 18px rgba(0,0,0,.12); }
|
||||
.pr-card-trigger { display: grid; grid-template-columns: minmax(280px, 1.35fr) minmax(230px, 1fr) 100px 28px; min-height: 74px; align-items: center; gap: 18px; padding: 12px 14px; cursor: pointer; }
|
||||
.pr-card-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.pr-card-identity { display: flex; min-width: 0; align-items: center; gap: 11px; }
|
||||
.pr-card-identity > div { min-width: 0; }
|
||||
.pr-icon { display: grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--line)); border-radius: 8px; background: color-mix(in srgb, var(--accent) 9%, var(--panel)); color: var(--accent-text); }
|
||||
.pr-icon svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.pr-link, .pr-link:visited { display: block; overflow: hidden; color: var(--ink); font-size: 12.5px; font-weight: var(--fw-subhead); text-decoration: none; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.pr-link:hover, .pr-link:focus-visible { color: var(--ink); text-decoration: underline; text-decoration-color: var(--mut2); text-underline-offset: 3px; }
|
||||
.pr-card-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
|
||||
.pr-card-meta span + span::before { content: '•'; margin: 0 7px; color: var(--line); }
|
||||
.pr-card-models { min-width: 0; }
|
||||
.pr-card-label { display: block; margin-bottom: 6px; color: var(--mut2); font-size: var(--fs-micro); font-weight: var(--fw-strong); letter-spacing: .05em; text-transform: uppercase; }
|
||||
.pr-model-list { display: flex; min-width: 0; flex-wrap: wrap; gap: 5px; }
|
||||
.pr-model-chip { display: inline-flex; max-width: 100%; align-items: center; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--fill); color: var(--mut); font-family: var(--mono); font-size: 10.5px; line-height: 1; white-space: nowrap; }
|
||||
.pr-card-cost { text-align: right; }
|
||||
.pr-card-cost strong { color: var(--ink); font-family: var(--mono); font-size: 14px; font-weight: var(--fw-strong); font-variant-numeric: tabular-nums; }
|
||||
.pr-chevron { display: grid; width: 26px; height: 26px; place-items: center; border: 1px solid var(--line); border-radius: 7px; background: var(--fill); color: var(--mut2); font-family: system-ui, sans-serif; font-size: 17px; line-height: 1; transition: transform 140ms ease, background 140ms ease; }
|
||||
.pr-card.is-open .pr-chevron { transform: rotate(90deg); }
|
||||
.pr-detail-cell { padding: 16px 18px 18px 57px; border-top: 1px solid var(--line2); background: color-mix(in srgb, var(--panel) 82%, var(--fill)); }
|
||||
.pr-detail { max-width: 880px; }
|
||||
.pr-detail-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.pr-detail-head span { color: var(--ink); font-size: var(--fs-body); font-weight: var(--fw-subhead); }
|
||||
.pr-detail-head strong { color: var(--mut); font-family: var(--mono); font-size: var(--fs-meta); font-weight: var(--fw-medium); font-variant-numeric: tabular-nums; }
|
||||
.pr-cats { display: flex; flex-direction: column; gap: 9px; }
|
||||
.pr-cat { display: grid; grid-template-columns: minmax(110px, 160px) minmax(120px, 1fr) 72px; align-items: center; gap: 12px; }
|
||||
.pr-cat-bar { height: 5px; overflow: hidden; border-radius: 3px; background: color-mix(in srgb, var(--fill) 82%, var(--mut2)); }
|
||||
.pr-cat-bar span { display: block; height: 100%; border-radius: inherit; background: color-mix(in srgb, var(--accent) 58%, white); }
|
||||
.pr-cat-name { overflow: hidden; color: var(--mut); font-size: 12px; font-weight: var(--fw-medium); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pr-cat > strong { color: var(--ink); font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.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-footnote { margin: 14px 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; }
|
||||
@media (max-width: 820px) {
|
||||
.pr-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.pr-card-trigger { grid-template-columns: minmax(230px, 1fr) 96px 28px; }
|
||||
.pr-card-models { grid-column: 1 / -1; grid-row: 2; padding-left: 43px; }
|
||||
.pr-detail-cell { padding-left: 18px; }
|
||||
}
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { ParsedProviderCall } from './providers/types.js'
|
|||
// so sessions cached under v4 pick up the CLI-MCP attribution.
|
||||
// v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from
|
||||
// patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add.
|
||||
const CODEX_CACHE_VERSION = 6
|
||||
const CODEX_CACHE_VERSION = 7
|
||||
const CACHE_FILE = 'codex-results.json'
|
||||
|
||||
type FileFingerprint = { mtimeMs: number; sizeBytes: number }
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export type PeriodData = {
|
|||
}
|
||||
|
||||
export type PullRequestsPayload = {
|
||||
/// Per-PR rows, cost-descending, capped at the top 20 by the producer.
|
||||
/// Every attributed PR row, cost-descending.
|
||||
rows: PrRow[]
|
||||
/// PR-linked spend, now INCLUDING the subagent runs folded into those sessions
|
||||
/// (so it can exceed the parents' own spend). Equals `attributedCost +
|
||||
|
|
@ -67,17 +67,11 @@ export type PullRequestsPayload = {
|
|||
/// sessions. Each remains a standalone row in the sessions list; here it only
|
||||
/// explains why the totals exceed the parents' own spend. 0 when none folded.
|
||||
subagentSessions?: number
|
||||
/// Sum of EVERY PR's attributed cost (all rows, not just the sent top 20).
|
||||
/// Sum of every PR's attributed cost.
|
||||
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 = {
|
||||
|
|
@ -309,11 +303,9 @@ 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 attributed by referenced pull request (top 20 by cost) plus the
|
||||
/// multi-link-safe distinct total. Rows are by-reference (a session
|
||||
/// referencing several PRs counts toward each), so never summed. Absent when
|
||||
/// no PR links were observed, and on payloads produced before the field
|
||||
/// existed — the client renders its empty state for both.
|
||||
/// Every pull request with attributed spend, cost-descending, plus the
|
||||
/// multi-link-safe distinct total. Absent when no PR links were observed and
|
||||
/// on payloads produced before the field existed.
|
||||
pullRequests?: PullRequestsPayload
|
||||
/// Per-branch spend (top 15 by cost), last-seen branch carried forward across
|
||||
/// each session's turns; a `null` branch is unbranched spend inside a
|
||||
|
|
|
|||
186
src/parser.ts
186
src/parser.ts
|
|
@ -3,7 +3,7 @@ import { lstat, readFile, readdir, stat } from 'fs/promises'
|
|||
import { basename, dirname, join, resolve, sep } from 'path'
|
||||
import { readSessionLines } from './fs-utils.js'
|
||||
import { calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash } from './models.js'
|
||||
import { sessionIdentity } from './sessions-report.js'
|
||||
import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js'
|
||||
import { normalizeContentBlocks } from './content-utils.js'
|
||||
import { discoverAllSessions, getProvider } from './providers/index.js'
|
||||
import { flushCodexCache } from './codex-cache.js'
|
||||
|
|
@ -1538,7 +1538,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
|
|||
currentTimestamp = entry.timestamp ?? ''
|
||||
currentSessionId = entry.sessionId ?? ''
|
||||
currentBranch = entryBranch
|
||||
currentPrRefs = []
|
||||
currentPrRefs = extractPrUrlsFromText(text)
|
||||
currentSpawnIds = []
|
||||
}
|
||||
} else if (entry.type === 'assistant') {
|
||||
|
|
@ -2038,8 +2038,10 @@ async function scanProjectDirs(
|
|||
// one was resolved there; only re-derive if the cached region had none.
|
||||
let canonicalCwd = cached.canonicalCwd
|
||||
let canonicalProjectName = cached.canonicalProjectName
|
||||
let workingDirectory = cached.workingDirectory
|
||||
if (canonicalCwd === undefined && newEntries) {
|
||||
const cwd = extractCanonicalCwd(newEntries)
|
||||
workingDirectory = workingDirectory ?? cwd
|
||||
const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined
|
||||
canonicalCwd = canonical?.path
|
||||
canonicalProjectName = canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined
|
||||
|
|
@ -2065,6 +2067,7 @@ async function scanProjectDirs(
|
|||
fingerprint: info.fp,
|
||||
lastCompleteLineOffset: tracker.lastCompleteLineOffset,
|
||||
canonicalCwd,
|
||||
...(workingDirectory ? { workingDirectory } : {}),
|
||||
canonicalProjectName,
|
||||
mcpInventory,
|
||||
turns: mergedTurns,
|
||||
|
|
@ -2101,6 +2104,7 @@ async function scanProjectDirs(
|
|||
fingerprint: info.fp,
|
||||
lastCompleteLineOffset: tracker.lastCompleteLineOffset,
|
||||
canonicalCwd: canonical?.path,
|
||||
...(cwd ? { workingDirectory: cwd } : {}),
|
||||
canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined,
|
||||
mcpInventory: extractMcpInventory(entries),
|
||||
turns: parsedTurnsToCachedTurns(turns),
|
||||
|
|
@ -2216,9 +2220,15 @@ async function scanProjectDirs(
|
|||
const projectName = cachedFile.canonicalProjectName ?? dirName
|
||||
const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined
|
||||
const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv, source)
|
||||
if (cachedFile.workingDirectory) session.workingDirectory = cachedFile.workingDirectory
|
||||
session.agentType = cachedFile.agentType
|
||||
if (everHadBranch) session.everHadBranch = true
|
||||
if (cachedFile.prLinks?.length) session.prLinks = [...new Set(cachedFile.prLinks)].sort()
|
||||
const observedPrLinks = new Set(classifiedTurns.flatMap(turn => turn.prRefs ?? []))
|
||||
for (const link of cachedFile.prLinks ?? []) observedPrLinks.add(link)
|
||||
if (observedPrLinks.size) {
|
||||
session.prLinks = [...observedPrLinks].sort()
|
||||
session.prAttributionSource = cachedFile.prLinks?.length ? 'transcript' : 'explicit-reference'
|
||||
}
|
||||
if (prRefsAtRangeStart?.length) session.prRefsAtRangeStart = prRefsAtRangeStart
|
||||
if (cachedFile.title) session.title = cachedFile.title
|
||||
// Sidechain linkage: carry the parent id (the transcript's internal
|
||||
|
|
@ -2299,6 +2309,14 @@ function summarizeProject(project: string, projectPath: string, sessions: Sessio
|
|||
}
|
||||
}
|
||||
|
||||
// Provider-neutral explicit-reference capture. Every saved provider session
|
||||
// passes through this boundary. Full URLs only: a bare "#123" is repository-
|
||||
// ambiguous and must never silently move spend between repositories.
|
||||
const PR_URL_IN_TEXT_RE = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+/g
|
||||
export function extractPrUrlsFromText(text: string): string[] {
|
||||
return [...new Set(text.match(PR_URL_IN_TEXT_RE) ?? [])].sort()
|
||||
}
|
||||
|
||||
function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
|
||||
const tools = call.tools
|
||||
const usage: TokenUsage = {
|
||||
|
|
@ -2329,11 +2347,13 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
|
|||
isEstimated: call.costIsEstimated,
|
||||
})
|
||||
|
||||
const prRefs = extractPrUrlsFromText(call.userMessage)
|
||||
return {
|
||||
userMessage: call.userMessage,
|
||||
assistantCalls: [apiCall],
|
||||
timestamp: call.timestamp,
|
||||
sessionId: call.sessionId,
|
||||
...(prRefs.length ? { prRefs } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2364,6 +2384,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
|
|||
deduplicationKey: call.deduplicationKey,
|
||||
project: call.project,
|
||||
projectPath: call.projectPath,
|
||||
workingDirectory: call.workingDirectory,
|
||||
toolSequence: call.toolSequence,
|
||||
...(call.locAdded ? { locAdded: call.locAdded } : {}),
|
||||
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
|
||||
|
|
@ -2375,10 +2396,11 @@ async function canonicalizeProviderCallProject(call: ParsedProviderCall): Promis
|
|||
if (!call.projectPath) return call
|
||||
|
||||
const canonical = await resolveCanonicalProjectPath(call.projectPath)
|
||||
if (!canonical.isWorktree) return call
|
||||
if (!canonical.isWorktree) return { ...call, workingDirectory: call.workingDirectory ?? call.projectPath }
|
||||
|
||||
return {
|
||||
...call,
|
||||
workingDirectory: call.workingDirectory ?? call.projectPath,
|
||||
project: projectNameFromPath(canonical.path, call.project ?? canonical.path),
|
||||
projectPath: canonical.path,
|
||||
}
|
||||
|
|
@ -2437,11 +2459,13 @@ export function parsedTurnsToCachedTurns(turns: ParsedTurn[]): CachedTurn[] {
|
|||
}
|
||||
|
||||
function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn {
|
||||
const prRefs = extractPrUrlsFromText(call.userMessage)
|
||||
return {
|
||||
timestamp: call.timestamp,
|
||||
sessionId: call.sessionId,
|
||||
userMessage: call.userMessage.slice(0, 2000),
|
||||
calls: [providerCallToCachedCall(call)],
|
||||
...(prRefs.length ? { prRefs } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2458,16 +2482,20 @@ function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] {
|
|||
const key = `${call.sessionId}\0${call.turnId}`
|
||||
let turn = grouped.get(key)
|
||||
if (!turn) {
|
||||
const prRefs = extractPrUrlsFromText(call.userMessage)
|
||||
turn = {
|
||||
timestamp: call.timestamp,
|
||||
sessionId: call.sessionId,
|
||||
userMessage: call.userMessage.slice(0, 2000),
|
||||
calls: [],
|
||||
...(prRefs.length ? { prRefs } : {}),
|
||||
}
|
||||
grouped.set(key, turn)
|
||||
turns.push(turn)
|
||||
}
|
||||
turn.calls.push(providerCallToCachedCall(call))
|
||||
const refs = extractPrUrlsFromText(call.userMessage)
|
||||
if (refs.length) turn.prRefs = [...new Set([...(turn.prRefs ?? []), ...refs])].sort()
|
||||
}
|
||||
|
||||
return turns
|
||||
|
|
@ -2519,13 +2547,14 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
|
|||
// and downstream date/day filtering can slice turns without losing the anchor.
|
||||
function cachedTurnToClassified(turn: CachedTurn, resolvedBranch?: string): ClassifiedTurn {
|
||||
const branch = turn.gitBranch ?? resolvedBranch
|
||||
const prRefs = turn.prRefs?.length ? turn.prRefs : extractPrUrlsFromText(turn.userMessage)
|
||||
const parsed: ParsedTurn = {
|
||||
userMessage: turn.userMessage,
|
||||
assistantCalls: turn.calls.map(cachedCallToApiCall),
|
||||
timestamp: turn.timestamp,
|
||||
sessionId: turn.sessionId,
|
||||
...(branch ? { gitBranch: branch } : {}),
|
||||
...(turn.prRefs?.length ? { prRefs: turn.prRefs } : {}),
|
||||
...(prRefs.length ? { prRefs } : {}),
|
||||
...(turn.spawnToolUseIds?.length ? { spawnToolUseIds: turn.spawnToolUseIds } : {}),
|
||||
}
|
||||
return classifyTurn(parsed)
|
||||
|
|
@ -2948,7 +2977,7 @@ async function parseProviderSources(
|
|||
|
||||
// Query-time: derive SessionSummary from all cached turns.
|
||||
// Uses seenKeys (shared across providers) for cross-provider dedup.
|
||||
const sessionMap = new Map<string, { project: string; projectPath?: string; turns: ClassifiedTurn[]; prLinks?: Set<string>; title?: string }>()
|
||||
const sessionMap = new Map<string, { project: string; projectPath?: string; workingDirectory?: string; turns: ClassifiedTurn[]; prLinks?: Set<string>; title?: string }>()
|
||||
|
||||
for (const source of servedSources) {
|
||||
const cachedFile = section.files[source.path]
|
||||
|
|
@ -2977,6 +3006,7 @@ async function parseProviderSources(
|
|||
if (!existing.projectPath && turn.calls[0]?.projectPath) {
|
||||
existing.projectPath = turn.calls[0]!.projectPath
|
||||
}
|
||||
if (!existing.workingDirectory && turn.calls[0]?.workingDirectory) existing.workingDirectory = turn.calls[0].workingDirectory
|
||||
if (cachedFile.prLinks?.length) {
|
||||
const links = (existing.prLinks ??= new Set())
|
||||
for (const link of cachedFile.prLinks) links.add(link)
|
||||
|
|
@ -2986,6 +3016,7 @@ async function parseProviderSources(
|
|||
sessionMap.set(key, {
|
||||
project,
|
||||
projectPath: turn.calls[0]?.projectPath,
|
||||
workingDirectory: turn.calls[0]?.workingDirectory,
|
||||
turns: [classified],
|
||||
...(cachedFile.prLinks?.length ? { prLinks: new Set(cachedFile.prLinks) } : {}),
|
||||
...(cachedFile.title ? { title: cachedFile.title } : {}),
|
||||
|
|
@ -3025,17 +3056,23 @@ async function parseProviderSources(
|
|||
existingEntry.projectPath = turn.calls[0]!.projectPath
|
||||
}
|
||||
} else {
|
||||
sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, turns: [classified] })
|
||||
sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, workingDirectory: turn.calls[0]?.workingDirectory, turns: [classified] })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const projectMap = new Map<string, { projectPath?: string; sessions: SessionSummary[] }>()
|
||||
for (const [key, { project, projectPath, turns, prLinks, title }] of sessionMap) {
|
||||
for (const [key, { project, projectPath, workingDirectory, turns, prLinks, title }] of sessionMap) {
|
||||
const sessionId = key.split(':')[1] ?? key
|
||||
const session = buildSessionSummary(sessionId, project, turns)
|
||||
if (prLinks?.size) session.prLinks = [...prLinks].sort()
|
||||
const explicitLinks = new Set(turns.flatMap(turn => turn.prRefs ?? []))
|
||||
for (const link of prLinks ?? []) explicitLinks.add(link)
|
||||
if (explicitLinks.size) {
|
||||
session.prLinks = [...explicitLinks].sort()
|
||||
session.prAttributionSource = prLinks?.size ? 'transcript' : 'explicit-reference'
|
||||
}
|
||||
if (workingDirectory) session.workingDirectory = workingDirectory
|
||||
if (title) session.title = title
|
||||
if (session.apiCalls > 0) {
|
||||
const existing = projectMap.get(project)
|
||||
|
|
@ -3145,6 +3182,8 @@ function isSpawnParent(session: SessionSummary): boolean {
|
|||
function carryLinkageFields(rebuilt: SessionSummary, original: SessionSummary): void {
|
||||
if (original.everHadBranch) rebuilt.everHadBranch = true
|
||||
if (original.prLinks?.length) rebuilt.prLinks = original.prLinks
|
||||
if (original.prAttributionSource) rebuilt.prAttributionSource = original.prAttributionSource
|
||||
if (original.workingDirectory) rebuilt.workingDirectory = original.workingDirectory
|
||||
// prRefsAtRangeStart is NOT copied here: a narrower slice needs it recomputed at
|
||||
// the new boundary (see recomputeRangeStartPrRefs), not the wide range's value.
|
||||
if (original.parentSessionId) rebuilt.parentSessionId = original.parentSessionId
|
||||
|
|
@ -3299,6 +3338,134 @@ export function mergeProjectsByCrossProviderKey(projects: ProjectSummary[]): Map
|
|||
return mergedMap
|
||||
}
|
||||
|
||||
function summaryProvider(session: SessionSummary): string {
|
||||
return session.turns.flatMap(t => t.assistantCalls)[0]?.provider ?? 'unknown'
|
||||
}
|
||||
|
||||
function normalizedWorkingDirectory(path: string | undefined): string | null {
|
||||
if (!path?.trim()) return null
|
||||
return path.trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
||||
}
|
||||
|
||||
function normalizedPrompt(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function assignCorrelatedPrs(
|
||||
session: SessionSummary,
|
||||
urls: readonly string[],
|
||||
source: 'working-directory' | 'launcher-prompt',
|
||||
): void {
|
||||
if (session.prLinks?.length || urls.length === 0) return
|
||||
const refs = [...new Set(urls)].sort()
|
||||
session.prLinks = refs
|
||||
session.prAttributionSource = source
|
||||
// Seed the first turn so the existing carry-forward state machine attributes
|
||||
// every later turn precisely. This is not the legacy whole-session split.
|
||||
if (session.turns[0] && !session.turns[0].prRefs?.length) session.turns[0].prRefs = refs
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlate saved sessions across AI providers without timestamp guessing.
|
||||
*
|
||||
* Evidence, strongest first:
|
||||
* 1. exact launch-prompt text embedded in a PR-linked session's shell command;
|
||||
* 2. exact provider-recorded cwd shared with one unambiguous PR.
|
||||
*
|
||||
* Timestamps only narrow prompt comparisons for performance; they can never
|
||||
* create attribution. Conflicting PR evidence is deliberately left unassigned.
|
||||
*/
|
||||
export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): void {
|
||||
const sessions = projects.flatMap(p => p.sessions)
|
||||
const linked = sessions.filter(s => s.prLinks?.length)
|
||||
// Claude sidechains retain their existing fold semantics. They may provide
|
||||
// evidence for a tool they launched, but must not become standalone PR rows.
|
||||
const candidates = sessions.filter(s => !s.prLinks?.length && !s.parentSessionId)
|
||||
const evidence = new Map<SessionSummary, string[]>(linked.map(s => [s, s.prLinks!]))
|
||||
|
||||
// Resolve Claude's native parent->sidechain linkage as evidence without
|
||||
// mutating the child. This lets a Codex/Gemini/etc. review launched inside a
|
||||
// Claude subagent inherit the parent turn's PR while the subagent itself still
|
||||
// folds exactly once under the existing accounting model.
|
||||
for (const resolved of resolveSubagentAttribution(projects).values()) {
|
||||
for (const child of resolved) {
|
||||
// A multi-PR spawn set is valid for folding the child's own cost, but is
|
||||
// too broad to identify which PR an independently saved nested review was
|
||||
// about. Require one PR for cross-provider propagation.
|
||||
if (child.unlinked || child.prSet?.length !== 1) continue
|
||||
const matches = sessions.filter(s => !s.prLinks?.length && s.agentId === child.fold.agentId)
|
||||
if (matches.length === 1) evidence.set(matches[0]!, child.prSet)
|
||||
}
|
||||
}
|
||||
|
||||
type Launch = { atMs: number; provider: string; refs: string[]; commands: string[] }
|
||||
const launches: Launch[] = []
|
||||
for (const [session, evidenceRefs] of evidence) {
|
||||
// A native PR-linked session's session-level union is NOT the active PR at
|
||||
// its beginning; only a range-start seed or a turn ref establishes that.
|
||||
// Sidechain evidence has already been resolved to its launching parent turn,
|
||||
// so it is safe to seed the otherwise ref-less child with that exact set.
|
||||
let active = session.prLinks?.length ? (session.prRefsAtRangeStart ?? []) : evidenceRefs
|
||||
for (const turn of session.turns) {
|
||||
if (turn.prRefs?.length) active = turn.prRefs
|
||||
if (active.length === 0) continue
|
||||
for (const call of turn.assistantCalls) {
|
||||
const commands = (call.toolSequence ?? [])
|
||||
.flat()
|
||||
.map(tool => typeof tool.command === 'string' ? normalizedPrompt(tool.command) : '')
|
||||
.filter(command => command.length > 0)
|
||||
if (commands.length === 0) continue
|
||||
const atMs = Date.parse(call.timestamp || turn.timestamp)
|
||||
if (Number.isFinite(atMs)) launches.push({ atMs, provider: call.provider, refs: active, commands })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PROMPT_PREFIX = 160
|
||||
const PROMPT_MIN = 80
|
||||
const LAUNCH_WINDOW_MS = 15 * 60 * 1000
|
||||
for (const session of candidates) {
|
||||
const provider = summaryProvider(session)
|
||||
const prompt = session.turns
|
||||
.map(t => normalizedPrompt(t.userMessage))
|
||||
.find(text => text.length >= PROMPT_MIN)
|
||||
if (!prompt) continue
|
||||
const prefix = prompt.slice(0, PROMPT_PREFIX)
|
||||
const startedMs = Date.parse(session.firstTimestamp)
|
||||
if (!Number.isFinite(startedMs)) continue
|
||||
const matches = launches.filter(launch =>
|
||||
launch.provider !== provider
|
||||
&& Math.abs(launch.atMs - startedMs) <= LAUNCH_WINDOW_MS
|
||||
&& launch.commands.some(command => command.includes(prefix))
|
||||
)
|
||||
const refSets = new Map(matches.map(m => [m.refs.slice().sort().join('\0'), m.refs]))
|
||||
if (refSets.size === 1) {
|
||||
assignCorrelatedPrs(session, [...refSets.values()][0]!, 'launcher-prompt')
|
||||
if (session.prLinks?.length) evidence.set(session, session.prLinks)
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt-linked sessions become valid cwd anchors too. Attribute only when an
|
||||
// exact cwd maps to one PR set; a main checkout used for multiple PRs remains
|
||||
// intentionally ambiguous.
|
||||
const refsByCwd = new Map<string, Map<string, string[]>>()
|
||||
for (const [session, evidenceRefs] of evidence) {
|
||||
const cwd = normalizedWorkingDirectory(session.workingDirectory)
|
||||
if (!cwd || evidenceRefs.length !== 1) continue
|
||||
const refs = evidenceRefs.slice().sort()
|
||||
const sets = refsByCwd.get(cwd) ?? new Map<string, string[]>()
|
||||
sets.set(refs.join('\0'), refs)
|
||||
refsByCwd.set(cwd, sets)
|
||||
}
|
||||
for (const session of sessions) {
|
||||
if (session.prLinks?.length || session.parentSessionId) continue
|
||||
const cwd = normalizedWorkingDirectory(session.workingDirectory)
|
||||
if (!cwd) continue
|
||||
const sets = refsByCwd.get(cwd)
|
||||
if (sets?.size === 1) assignCorrelatedPrs(session, [...sets.values()][0]!, 'working-directory')
|
||||
}
|
||||
}
|
||||
|
||||
export function filterProjectsByClaudeConfigSource(projects: ProjectSummary[], sourceId: string): ProjectSummary[] {
|
||||
const filtered: ProjectSummary[] = []
|
||||
for (const project of projects) {
|
||||
|
|
@ -3566,6 +3733,7 @@ async function runParse(
|
|||
}
|
||||
|
||||
const result = Array.from(mergedMap.values()).sort((a, b) => b.totalCostUSD - a.totalCostUSD)
|
||||
correlateCrossProviderPrSessions(result)
|
||||
cachePut(key, result)
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -382,6 +382,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
|
||||
let sessionModel: string | undefined
|
||||
let sessionId = ''
|
||||
let sessionCwd: string | undefined
|
||||
let forkedFromId = ''
|
||||
let forkCutoff = ''
|
||||
// Null sentinel rather than `0` so the FIRST event is never confused
|
||||
|
|
@ -422,6 +423,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
|
||||
if (entry.type === 'session_meta') {
|
||||
sessionId = entry.payload?.session_id ?? basename(source.path, '.jsonl')
|
||||
sessionCwd = entry.payload?.cwd ?? sessionCwd
|
||||
forkedFromId = entry.payload?.forked_from_id ?? ''
|
||||
if (forkedFromId && entry.timestamp) {
|
||||
forkCutoff = new Date(new Date(entry.timestamp).getTime() + 5000).toISOString()
|
||||
|
|
@ -561,6 +563,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
|
||||
userMessage: pendingUserMessage,
|
||||
sessionId,
|
||||
...(sessionCwd ? { projectPath: sessionCwd, workingDirectory: sessionCwd } : {}),
|
||||
...(pendingLocAdded ? { locAdded: pendingLocAdded } : {}),
|
||||
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
|
||||
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
|
||||
|
|
@ -677,6 +680,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
|
||||
userMessage: pendingUserMessage,
|
||||
sessionId,
|
||||
...(sessionCwd ? { projectPath: sessionCwd, workingDirectory: sessionCwd } : {}),
|
||||
...(pendingLocAdded ? { locAdded: pendingLocAdded } : {}),
|
||||
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
|
||||
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ export type ParsedProviderCall = {
|
|||
sessionId: string
|
||||
project?: string
|
||||
projectPath?: string
|
||||
// Exact provider-recorded cwd, kept separately because projectPath may later
|
||||
// canonicalize a linked worktree to its main repository.
|
||||
workingDirectory?: string
|
||||
}
|
||||
|
||||
// A directory or database file that a provider's discoverSessions() scans.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export type CachedCall = {
|
|||
deduplicationKey: string
|
||||
project?: string
|
||||
projectPath?: string
|
||||
workingDirectory?: string
|
||||
toolSequence?: ToolCall[][]
|
||||
// Rich-session-capture (capture-only; no report consumes these yet). All
|
||||
// optional and omitted at zero/false to keep the per-call cache cost minimal.
|
||||
|
|
@ -61,9 +62,9 @@ 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.
|
||||
// GitHub PR URLs referenced during this turn, sorted and deduplicated. Claude
|
||||
// can provide native links; all providers can provide explicit URLs from the
|
||||
// saved user message. Stored directly so each turn's refs are self-contained.
|
||||
prRefs?: string[]
|
||||
// Claude: `tool_use` ids of the `Agent`/`Task` subagent spawns in this turn.
|
||||
// A spawned sidechain session is folded into the launching turn by matching its
|
||||
|
|
@ -82,6 +83,8 @@ export type CachedFile = {
|
|||
fingerprint: FileFingerprint
|
||||
lastCompleteLineOffset?: number
|
||||
canonicalCwd?: string
|
||||
// Original cwd before linked-worktree canonicalization.
|
||||
workingDirectory?: string
|
||||
canonicalProjectName?: string
|
||||
mcpInventory: string[]
|
||||
turns: CachedTurn[]
|
||||
|
|
@ -200,7 +203,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// LOC deltas / interruptions / userModified / toolErrors, and session-level
|
||||
// title / prLinks / isSidechain. Forces one re-parse so cached sessions gain
|
||||
// the new optional fields.
|
||||
claude: 'advisor-usage-v1-skills-rich-capture-v1',
|
||||
claude: 'advisor-usage-v1-skills-rich-capture-v1-cross-provider-pr-v1',
|
||||
cline: 'worktree-project-grouping-v1',
|
||||
codewhale: 'aggregate-session-v1-est-cost',
|
||||
// Bump when the Codex parser changes attribution so unchanged, already-cached
|
||||
|
|
@ -210,7 +213,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// rich-session-capture-v1: per-call LOC deltas + editFailed from
|
||||
// patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in
|
||||
// lockstep so the pre-session-cache layer re-parses too.)
|
||||
codex: 'mcp-attribution-v2-est-cost-rich-capture-v1',
|
||||
codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1',
|
||||
cursor: 'composer-anchored-crediting-v1-est-cost',
|
||||
'cursor-agent': 'workspaceless-transcript-v1',
|
||||
copilot: 'cli-shutdown-cost-v1-skills',
|
||||
|
|
@ -340,6 +343,7 @@ function validateCall(c: unknown): c is CachedCall {
|
|||
&& (o['subagentTypes'] === undefined || isStringArray(o['subagentTypes']))
|
||||
&& isOptionalString(o['project'])
|
||||
&& isOptionalString(o['projectPath'])
|
||||
&& isOptionalString(o['workingDirectory'])
|
||||
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s))))
|
||||
&& isOptionalNum(o['locAdded'])
|
||||
&& isOptionalNum(o['locRemoved'])
|
||||
|
|
@ -369,6 +373,7 @@ function validateCachedFile(f: unknown): f is CachedFile {
|
|||
return validateFingerprint(o['fingerprint'])
|
||||
&& isOptionalNum(o['lastCompleteLineOffset'])
|
||||
&& isOptionalString(o['canonicalCwd'])
|
||||
&& isOptionalString(o['workingDirectory'])
|
||||
&& isOptionalString(o['canonicalProjectName'])
|
||||
&& isStringArray(o['mcpInventory'])
|
||||
&& isOptionalString(o['title'])
|
||||
|
|
|
|||
|
|
@ -661,12 +661,17 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution {
|
|||
.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.
|
||||
// ties for a stable order). Keep every real model: `<synthetic>` is an
|
||||
// internal accounting bucket, not a model a person chose or can act on.
|
||||
// The desktop renders the rest as wrapping chips, so an opaque "+N" never
|
||||
// hides which model did the work.
|
||||
const shortCosts = new Map<string, number>()
|
||||
for (const [raw, mc] of r.models) addToMap(shortCosts, getShortModelName(raw), mc)
|
||||
for (const [raw, mc] of r.models) {
|
||||
if (raw === '<synthetic>') continue
|
||||
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 }))
|
||||
|
|
|
|||
20
src/types.ts
20
src/types.ts
|
|
@ -94,10 +94,10 @@ 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.
|
||||
// GitHub PR URLs referenced during this turn, sorted and deduplicated. Claude
|
||||
// supplies native `pr-link` entries; every provider can contribute explicit
|
||||
// URLs from its saved user message, and correlated external sessions seed the
|
||||
// field deterministically. Drives turn-level PR spend attribution.
|
||||
prRefs?: string[]
|
||||
// Claude Code: the `tool_use` ids of the `Agent`/`Task` subagent spawns emitted
|
||||
// in this turn. A spawned sidechain session is folded back into the turn that
|
||||
|
|
@ -193,6 +193,14 @@ export type SessionSourceMetadata = {
|
|||
export type SessionSummary = {
|
||||
sessionId: string
|
||||
project: string
|
||||
/// Exact working directory recorded by the provider before git-worktree
|
||||
/// canonicalization. Used to correlate sessions from different AI tools that
|
||||
/// worked in the same checkout. Never synthesized from timestamps.
|
||||
workingDirectory?: string
|
||||
/// How this session became associated with its PR links. Native transcript
|
||||
/// links are strongest; the other sources are deterministic cross-provider
|
||||
/// correlations performed after all saved sessions have been parsed.
|
||||
prAttributionSource?: 'transcript' | 'explicit-reference' | 'working-directory' | 'launcher-prompt'
|
||||
source?: SessionSourceMetadata
|
||||
// Claude Code only: agent type of a subagent transcript session
|
||||
// (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for
|
||||
|
|
@ -241,8 +249,8 @@ export type SessionSummary = {
|
|||
totalCacheWriteTokens: number
|
||||
apiCalls: number
|
||||
turns: ClassifiedTurn[]
|
||||
/// GitHub PR URLs captured from the session transcript (session-level,
|
||||
/// deduplicated). Absent when none were observed.
|
||||
/// GitHub PR URLs captured or deterministically correlated for this session
|
||||
/// (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
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateS
|
|||
import { buildGranularHistory } from './granular-history.js'
|
||||
|
||||
// Row caps for the by-PR / by-branch payload aggregations, ranked by cost.
|
||||
const TOP_PULL_REQUESTS = 20
|
||||
const TOP_BRANCHES = 15
|
||||
|
||||
export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||
|
|
@ -657,7 +656,8 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
// PULL REQUESTS + BRANCHES (all-provider path only). Both are session-layer
|
||||
// aggregations over the surviving-session parse, so carried history cannot
|
||||
// contribute — expected and fine. PR links and per-turn git branches are
|
||||
// captured only from Claude transcripts today; other providers add nothing.
|
||||
// captured natively by Claude or correlated from any provider's saved session
|
||||
// through explicit references, exact launcher prompts, or unambiguous cwds.
|
||||
// Set only when non-empty so the payload omits them (and the app renders its
|
||||
// quiet empty state) whenever there is nothing to show. Excluded on the
|
||||
// Claude-config-scoped path (which replaces scanProjects with one config's
|
||||
|
|
@ -666,16 +666,14 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
// One pass yields both rows and totals, so they never disagree.
|
||||
const { rows: prRows, totals: prTotals } = buildPrAttribution(scanProjects)
|
||||
if (prRows.length > 0) {
|
||||
const shownRows = prRows.slice(0, TOP_PULL_REQUESTS)
|
||||
const otherRows = prRows.slice(TOP_PULL_REQUESTS)
|
||||
currentData.pullRequests = {
|
||||
rows: shownRows,
|
||||
// PRs are user-auditable spend records, so never collapse the tail into
|
||||
// an opaque "Other" bucket. The desktop list scrolls with the page.
|
||||
rows: prRows,
|
||||
distinctCost: prTotals.cost,
|
||||
distinctSessions: prTotals.sessions,
|
||||
attributedCost: prTotals.attributedCost,
|
||||
unattributedCost: prTotals.unattributedCost,
|
||||
otherPrCount: otherRows.length,
|
||||
otherPrCost: otherRows.reduce((sum, r) => sum + r.cost, 0),
|
||||
...(prTotals.subagentSessions > 0 ? { subagentSessions: prTotals.subagentSessions } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
108
tests/cross-provider-pr-correlation.test.ts
Normal file
108
tests/cross-provider-pr-correlation.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { correlateCrossProviderPrSessions, extractPrUrlsFromText } from '../src/parser.js'
|
||||
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary } from '../src/types.js'
|
||||
|
||||
const A = 'https://github.com/getagentseal/codeburn/pull/790'
|
||||
const B = 'https://github.com/getagentseal/codeburn/pull/791'
|
||||
|
||||
function call(provider: string, timestamp: string, command?: string): ParsedApiCall {
|
||||
return {
|
||||
provider, model: provider === 'claude' ? 'claude-opus-4-8' : 'gpt-5.6-terra',
|
||||
usage: { inputTokens: 1, outputTokens: 1, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 },
|
||||
costUSD: 1, tools: command ? ['Bash'] : [], mcpTools: [], skills: [], subagentTypes: [],
|
||||
hasAgentSpawn: false, hasPlanMode: false, speed: 'standard', timestamp,
|
||||
bashCommands: command ? ['codex'] : [], deduplicationKey: `${provider}:${timestamp}`,
|
||||
...(command ? { toolSequence: [[{ tool: 'Bash', command }]] } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function turn(provider: string, timestamp: string, userMessage: string, refs?: string[], command?: string): ClassifiedTurn {
|
||||
return {
|
||||
userMessage, timestamp, sessionId: `${provider}-${timestamp}`,
|
||||
assistantCalls: [call(provider, timestamp, command)], category: 'coding', retries: 0, hasEdits: false,
|
||||
...(refs ? { prRefs: refs } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function session(opts: { id: string; provider: string; timestamp: string; message: string; refs?: string[]; cwd?: string; command?: string; parentId?: string; agentId?: string }): SessionSummary {
|
||||
const turns = [turn(opts.provider, opts.timestamp, opts.message, opts.refs, opts.command)]
|
||||
return {
|
||||
sessionId: opts.id, project: 'codeburn', firstTimestamp: opts.timestamp, lastTimestamp: opts.timestamp,
|
||||
totalCostUSD: 1, totalSavingsUSD: 0, totalInputTokens: 1, totalOutputTokens: 1,
|
||||
totalReasoningTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 1, turns,
|
||||
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {}, skillBreakdown: {}, subagentBreakdown: {},
|
||||
...(opts.refs ? { prLinks: opts.refs, prAttributionSource: 'transcript' as const } : {}),
|
||||
...(opts.cwd ? { workingDirectory: opts.cwd } : {}),
|
||||
...(opts.parentId ? { parentSessionId: opts.parentId } : {}),
|
||||
...(opts.agentId ? { agentId: opts.agentId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function project(sessions: SessionSummary[]): ProjectSummary {
|
||||
return { project: 'codeburn', projectPath: '/repo/codeburn', sessions, totalCostUSD: sessions.length, totalSavingsUSD: 0, totalApiCalls: sessions.length, totalProxiedCostUSD: 0 }
|
||||
}
|
||||
|
||||
describe('provider-neutral PR references', () => {
|
||||
it('extracts and deduplicates full GitHub PR URLs from any provider text', () => {
|
||||
expect(extractPrUrlsFromText(`review ${A}, then ${B}; duplicate ${A}`)).toEqual([A, B])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cross-provider PR correlation', () => {
|
||||
const prompt = 'Adversarial review of the cross-provider pull-request attribution implementation with concrete failure scenarios.'
|
||||
|
||||
it('links an externally launched saved session by exact prompt evidence', () => {
|
||||
const parent = session({ id: 'claude', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'launch review', refs: [B], command: `codex exec '${prompt}'` })
|
||||
const child = session({ id: 'codex', provider: 'codex', timestamp: '2026-07-21T00:00:05Z', message: prompt })
|
||||
correlateCrossProviderPrSessions([project([parent, child])])
|
||||
expect(child.prLinks).toEqual([B])
|
||||
expect(child.turns[0]!.prRefs).toEqual([B])
|
||||
expect(child.prAttributionSource).toBe('launcher-prompt')
|
||||
})
|
||||
|
||||
it('does not use timestamp overlap without exact prompt evidence', () => {
|
||||
const parent = session({ id: 'claude', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'launch review', refs: [B], command: `codex exec '${prompt}'` })
|
||||
const unrelated = session({ id: 'codex', provider: 'codex', timestamp: '2026-07-21T00:00:05Z', message: 'Investigate a completely unrelated production database incident and prepare a detailed report.' })
|
||||
correlateCrossProviderPrSessions([project([parent, unrelated])])
|
||||
expect(unrelated.prLinks).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not treat a session-level PR union as active before its first turn ref', () => {
|
||||
const parent = session({ id: 'claude', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'launch', refs: [A, B], command: `codex exec '${prompt}'` })
|
||||
delete parent.turns[0]!.prRefs
|
||||
const child = session({ id: 'codex', provider: 'codex', timestamp: '2026-07-21T00:00:05Z', message: prompt })
|
||||
correlateCrossProviderPrSessions([project([parent, child])])
|
||||
expect(child.prLinks).toBeUndefined()
|
||||
})
|
||||
|
||||
it('links an exact shared cwd only when it resolves to one PR set', () => {
|
||||
const cwd = '/repo/.claude/worktrees/agent-123'
|
||||
const parent = session({ id: 'claude', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'work', refs: [B], cwd })
|
||||
const child = session({ id: 'gemini', provider: 'gemini', timestamp: '2026-07-21T01:00:00Z', message: 'short', cwd })
|
||||
correlateCrossProviderPrSessions([project([parent, child])])
|
||||
expect(child.prLinks).toEqual([B])
|
||||
expect(child.prAttributionSource).toBe('working-directory')
|
||||
})
|
||||
|
||||
it('leaves a shared cwd unattributed when two PRs used it', () => {
|
||||
const cwd = '/repo/codeburn'
|
||||
const a = session({ id: 'a', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'a', refs: [A], cwd })
|
||||
const b = session({ id: 'b', provider: 'claude', timestamp: '2026-07-21T01:00:00Z', message: 'b', refs: [B], cwd })
|
||||
const candidate = session({ id: 'c', provider: 'codex', timestamp: '2026-07-21T02:00:00Z', message: 'short', cwd })
|
||||
correlateCrossProviderPrSessions([project([a, b, candidate])])
|
||||
expect(candidate.prLinks).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses Claude sidechain linkage as evidence without breaking its fold semantics', () => {
|
||||
const parent = session({ id: 'parent', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'spawn', refs: [B] })
|
||||
parent.agentSpawnLinks = { child: 'spawn-1' }
|
||||
parent.spawnPrSets = { 'spawn-1': [B] }
|
||||
const child = session({ id: 'agent-child', provider: 'claude', timestamp: '2026-07-21T00:01:00Z', message: 'launch', command: `codex exec '${prompt}'`, parentId: 'parent', agentId: 'child' })
|
||||
const codex = session({ id: 'codex', provider: 'codex', timestamp: '2026-07-21T00:01:05Z', message: prompt })
|
||||
correlateCrossProviderPrSessions([project([parent, child, codex])])
|
||||
expect(child.prLinks).toBeUndefined()
|
||||
expect(codex.prLinks).toEqual([B])
|
||||
expect(codex.prAttributionSource).toBe('launcher-prompt')
|
||||
})
|
||||
})
|
||||
|
|
@ -57,16 +57,12 @@ describe('buildMenubarPayload', () => {
|
|||
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)', () => {
|
||||
|
|
|
|||
|
|
@ -334,12 +334,12 @@ describe('mixed live + legacy category reconciliation (round-3 finding 1)', () =
|
|||
})
|
||||
})
|
||||
|
||||
describe('model list bounds (round-3 finding 5)', () => {
|
||||
it('caps a row to the top 4 models by attributed cost', () => {
|
||||
describe('model list ordering', () => {
|
||||
it('keeps every model ordered 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]])]),
|
||||
sessionWithTurns('s', [A], [turnModels([A], [['<synthetic>', 70], ['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'])
|
||||
expect(rows[0]!.models).toEqual(['m-f', 'm-e', 'm-d', 'm-c', 'm-b', 'm-a'])
|
||||
})
|
||||
|
||||
it('breaks model ties by name ascending for a stable order', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue