mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-11-01 12:01:05 +00:00
* feat(sessions): fold subagent sessions into their parent row The default sessions list (table, JSON, TUI, contributions, desktop) now shows one row per parent session with its provider-recorded subagents summed in and listed under subagents. Grouping reuses the work-unit resolver, so only rows move; totals are unchanged. --by-work-unit keeps the flat rows. * test: reconcile the session diagnosis with the folded sessions row * fix(sessions): find and open a parent row by its subagents, count subagents apart from top-level rows
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
applyInvestigation,
|
|
branchFilters,
|
|
categoryFilters,
|
|
dayFilters,
|
|
EMPTY_FILTERS,
|
|
filterChipKey,
|
|
filterChipLabel,
|
|
filtersActive,
|
|
filtersToKey,
|
|
modelFilters,
|
|
normalizeFilters,
|
|
prFilters,
|
|
projectFilters,
|
|
providerFilters,
|
|
sessionFilters,
|
|
withFilterValue,
|
|
withoutFilterValue,
|
|
} from './investigation'
|
|
import type { ContributionSegment, SessionDrillRow } from './types'
|
|
|
|
function segment(overrides: Partial<ContributionSegment>): ContributionSegment {
|
|
return {
|
|
day: '2026-09-10',
|
|
category: 'coding',
|
|
branch: null,
|
|
models: { 'Sonnet 4.5': 0 },
|
|
prs: [],
|
|
cost: 0,
|
|
calls: 0,
|
|
savingsUSD: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function row(overrides: Partial<SessionDrillRow> & Pick<SessionDrillRow, 'sessionId'>): SessionDrillRow {
|
|
return {
|
|
title: '',
|
|
project: '/repo/app',
|
|
provider: 'claude',
|
|
models: ['Sonnet 4.5'],
|
|
cost: 0,
|
|
savingsUSD: 0,
|
|
calls: 0,
|
|
turns: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
startedAt: '2026-09-10T10:00:00.000Z',
|
|
endedAt: '2026-09-10T11:00:00.000Z',
|
|
durationMs: 3_600_000,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const PR_X = 'https://github.com/owner/repo-x/pull/123'
|
|
const PR_Y = 'https://github.com/owner/repo-y/pull/456'
|
|
|
|
describe('filters normalization', () => {
|
|
it('dedupes, sorts, and gives identical selections identical keys', () => {
|
|
const a = normalizeFilters({ ...EMPTY_FILTERS, days: ['2026-09-11', '2026-09-10', '2026-09-10'], models: ['B', 'A'] })
|
|
const b = normalizeFilters({ ...EMPTY_FILTERS, days: ['2026-09-10', '2026-09-11'], models: ['A', 'B'] })
|
|
expect(a.days).toEqual(['2026-09-10', '2026-09-11'])
|
|
expect(a.models).toEqual(['A', 'B'])
|
|
expect(filtersToKey(a)).toBe(filtersToKey(b))
|
|
})
|
|
|
|
it('reports activity and empty state', () => {
|
|
expect(filtersActive(EMPTY_FILTERS)).toBe(false)
|
|
expect(filtersActive(categoryFilters('coding'))).toBe(true)
|
|
})
|
|
|
|
it('adds and removes a value within a dimension (the OR rule)', () => {
|
|
const next = withFilterValue(dayFilters('2026-09-10'), 'days', '2026-09-11')
|
|
expect(next.days).toEqual(['2026-09-10', '2026-09-11'])
|
|
const back = withoutFilterValue(next, 'days', '2026-09-10')
|
|
expect(back.days).toEqual(['2026-09-11'])
|
|
})
|
|
})
|
|
|
|
describe('contribution math', () => {
|
|
it('the 100/20 category fixture: selection sums 20, full cost labeled 100', () => {
|
|
// Session cost 1.00 with 0.20 coding and 0.80 debugging. The coding
|
|
// selection must show 0.20 — never the whole 1.00 — and the full cost
|
|
// stays available as a separate labeled figure.
|
|
const subject = row({
|
|
sessionId: 's1',
|
|
cost: 1.0,
|
|
calls: 10,
|
|
contributions: { segments: [
|
|
segment({ category: 'coding', cost: 0.2, calls: 2 }),
|
|
segment({ category: 'debugging', cost: 0.8, calls: 8 }),
|
|
] },
|
|
})
|
|
const result = applyInvestigation([subject], categoryFilters('coding'))
|
|
expect(result.cost).toBeCloseTo(0.2, 10)
|
|
expect(result.calls).toBe(2)
|
|
expect(result.fullCost).toBeCloseTo(1.0, 10)
|
|
expect(result.included).toHaveLength(1)
|
|
})
|
|
|
|
it('a day selection includes sessions started earlier that were active that day', () => {
|
|
const early = row({
|
|
sessionId: 'early',
|
|
project: '/repo/app',
|
|
cost: 1.0,
|
|
startedAt: '2026-09-09T08:00:00.000Z',
|
|
contributions: { segments: [
|
|
segment({ day: '2026-09-09', cost: 0.6 }),
|
|
segment({ day: '2026-09-10', cost: 0.4 }),
|
|
] },
|
|
})
|
|
const result = applyInvestigation([early], dayFilters('2026-09-10'))
|
|
expect(result.cost).toBeCloseTo(0.4, 10)
|
|
// Both days together union within the dimension and reconcile the session.
|
|
const both = applyInvestigation([early], dayFilters('2026-09-09', '2026-09-10'))
|
|
expect(both.cost).toBeCloseTo(1.0, 10)
|
|
})
|
|
|
|
it('different dimensions intersect with AND, same dimension unions with OR', () => {
|
|
const subject = row({
|
|
sessionId: 'mixed',
|
|
cost: 0.8,
|
|
contributions: { segments: [
|
|
segment({ day: '2026-09-10', category: 'coding', cost: 0.1 }),
|
|
segment({ day: '2026-09-10', category: 'debugging', cost: 0.3 }),
|
|
segment({ day: '2026-09-11', category: 'coding', cost: 0.4 }),
|
|
] },
|
|
})
|
|
// day1 AND coding = 0.1 (not the day total 0.4, not the category total 0.5).
|
|
const andResult = applyInvestigation([subject], withFilterValue(dayFilters('2026-09-10'), 'categories', 'coding'))
|
|
expect(andResult.cost).toBeCloseTo(0.1, 10)
|
|
// day1 OR day2 over coding = 0.1 + 0.4.
|
|
const orResult = applyInvestigation(
|
|
[subject],
|
|
withFilterValue(withFilterValue(dayFilters('2026-09-10'), 'days', '2026-09-11'), 'categories', 'coding'),
|
|
)
|
|
expect(orResult.cost).toBeCloseTo(0.5, 10)
|
|
})
|
|
|
|
it('exact project identity keeps prefix-similar projects apart', () => {
|
|
const app = row({ sessionId: 'a', project: '-work-app', projectId: '/work/app', cost: 1 })
|
|
const appBeta = row({ sessionId: 'b', project: '-work-app-beta', projectId: '/work/app-beta', cost: 2 })
|
|
const result = applyInvestigation([app, appBeta], projectFilters('/work/app'))
|
|
expect(result.included.map(entry => entry.row.sessionId)).toEqual(['a'])
|
|
expect(result.cost).toBeCloseTo(1, 10)
|
|
// Rows from an older CLI without projectId still match their exact raw path.
|
|
const legacyApp = row({ sessionId: 'legacy', project: '/work/app', cost: 0.5 })
|
|
const legacyResult = applyInvestigation([legacyApp, appBeta], projectFilters('/work/app'))
|
|
expect(legacyResult.included.map(entry => entry.row.sessionId)).toEqual(['legacy'])
|
|
})
|
|
|
|
it('two repositories with PR #123 stay distinct; a multi-PR set contributes its share', () => {
|
|
const repoX = row({
|
|
sessionId: 'x',
|
|
cost: 0.6,
|
|
contributions: { segments: [segment({ prs: [PR_X], cost: 0.6 })] },
|
|
})
|
|
const repoY = row({
|
|
sessionId: 'y',
|
|
cost: 0.8,
|
|
contributions: { segments: [segment({ prs: [PR_Y], cost: 0.8 })] },
|
|
})
|
|
const merged = row({
|
|
sessionId: 'm',
|
|
cost: 1.0,
|
|
contributions: { segments: [segment({ prs: [PR_X, PR_Y], cost: 1.0 })] },
|
|
})
|
|
const xSelection = applyInvestigation([repoX, repoY, merged], prFilters(PR_X))
|
|
// repoX's own 0.6 plus HALF of the merge-sweep session's 1.0.
|
|
expect(xSelection.cost).toBeCloseTo(0.6 + 0.5, 10)
|
|
expect(xSelection.included.map(entry => entry.row.sessionId).sort()).toEqual(['m', 'x'])
|
|
})
|
|
|
|
it('branch is scoped by project: two projects sharing main stay separate', () => {
|
|
const p1 = row({
|
|
sessionId: 'p1',
|
|
project: '-work-one',
|
|
projectId: '/work/one',
|
|
cost: 0.5,
|
|
contributions: { segments: [segment({ branch: 'main', cost: 0.5 })] },
|
|
})
|
|
const p2 = row({
|
|
sessionId: 'p2',
|
|
project: '-work-two',
|
|
projectId: '/work/two',
|
|
cost: 0.7,
|
|
contributions: { segments: [segment({ branch: 'main', cost: 0.7 })] },
|
|
})
|
|
const result = applyInvestigation([p1, p2], branchFilters('/work/two', 'main'))
|
|
expect(result.included.map(entry => entry.row.sessionId)).toEqual(['p2'])
|
|
expect(result.cost).toBeCloseTo(0.7, 10)
|
|
})
|
|
|
|
it('the same session id under two providers opens the right session', () => {
|
|
const claude = row({ sessionId: 'shared-id', provider: 'claude', cost: 1 })
|
|
const codex = row({ sessionId: 'shared-id', provider: 'codex', cost: 2 })
|
|
const claudeSelection = applyInvestigation([claude, codex], sessionFilters({ provider: 'codex', sessionId: 'shared-id' }))
|
|
expect(claudeSelection.cost).toBeCloseTo(2, 10)
|
|
expect(claudeSelection.included[0]!.row.provider).toBe('codex')
|
|
})
|
|
|
|
it('a session chip for a folded subagent selects its parent row', () => {
|
|
const parent = row({ sessionId: 'parent', provider: 'claude', cost: 3, subagents: [row({ sessionId: 'agent-x', provider: 'claude', cost: 1 })] })
|
|
const other = row({ sessionId: 'other', provider: 'claude', cost: 5 })
|
|
const selection = applyInvestigation([parent, other], sessionFilters({ provider: 'claude', sessionId: 'agent-x' }))
|
|
expect(selection.included.map(entry => entry.row.sessionId)).toEqual(['parent'])
|
|
})
|
|
|
|
it('model selections use the canonical short-name identity', () => {
|
|
const subject = row({
|
|
sessionId: 'mm',
|
|
cost: 0.9,
|
|
contributions: { segments: [segment({
|
|
models: { 'Sonnet 4.5': 0.3, 'GPT-5.5': 0.6 },
|
|
modelUsage: { 'Sonnet 4.5': { calls: 1, inputTokens: 100, outputTokens: 20 }, 'GPT-5.5': { calls: 1, inputTokens: 200, outputTokens: 40 } },
|
|
cost: 0.9,
|
|
})] },
|
|
})
|
|
expect(applyInvestigation([subject], modelFilters(['GPT-5.5'])).cost).toBeCloseTo(0.6, 10)
|
|
expect(applyInvestigation([subject], modelFilters(['Sonnet 4.5'])).cost).toBeCloseTo(0.3, 10)
|
|
})
|
|
|
|
it('provider and project chips gate whole rows and intersect with segment dimensions', () => {
|
|
const claude = row({
|
|
sessionId: 'c', provider: 'claude', cost: 0.5,
|
|
contributions: { segments: [segment({ category: 'coding', cost: 0.5 })] },
|
|
})
|
|
const codex = row({
|
|
sessionId: 'x', provider: 'codex', cost: 0.9,
|
|
contributions: { segments: [segment({ category: 'coding', cost: 0.9 })] },
|
|
})
|
|
// Provider dimension alone: whole-session contributions.
|
|
const providers = applyInvestigation([claude, codex], withFilterValue(providerFilters('claude'), 'providers', 'codex'))
|
|
expect(providers.cost).toBeCloseTo(1.4, 10)
|
|
// Provider AND category: only the matching provider's category spend.
|
|
const andResult = applyInvestigation([claude, codex], withFilterValue(providerFilters('codex'), 'categories', 'coding'))
|
|
expect(andResult.cost).toBeCloseTo(0.9, 10)
|
|
})
|
|
|
|
it('uses actual per-model requests and tokens, including zero-cost usage', () => {
|
|
const subject = row({ sessionId: 'priced-differently', cost: 10, contributions: { segments: [segment({
|
|
cost: 10, calls: 3, inputTokens: 1050,
|
|
models: { expensive: 9, cheap: 1, free: 0 },
|
|
modelUsage: {
|
|
expensive: { calls: 1, inputTokens: 100, outputTokens: 0 },
|
|
cheap: { calls: 1, inputTokens: 900, outputTokens: 0 },
|
|
free: { calls: 1, inputTokens: 50, outputTokens: 0 },
|
|
},
|
|
})] } })
|
|
expect(applyInvestigation([subject], modelFilters(['expensive']))).toMatchObject({ cost: 9, calls: 1, tokens: 100 })
|
|
expect(applyInvestigation([subject], modelFilters(['free']))).toMatchObject({ cost: 0, calls: 1, tokens: 50 })
|
|
expect(applyInvestigation([subject], modelFilters(['expensive', 'cheap', 'free']))).toMatchObject({ cost: 10, calls: 3, tokens: 1050 })
|
|
})
|
|
|
|
it('discloses old model payloads without counting rows excluded by provider', () => {
|
|
const legacy = row({ sessionId: 'legacy', cost: 2, contributions: { segments: [segment({ cost: 2, models: { old: 2 } })] } })
|
|
const otherProvider = row({ sessionId: 'other', provider: 'codex', cost: 3 })
|
|
const result = applyInvestigation([legacy, otherProvider], withFilterValue(modelFilters(['old']), 'providers', 'claude'))
|
|
expect(result.included).toEqual([])
|
|
expect(result.unattributable).toBe(1)
|
|
})
|
|
|
|
it('rows without contribution segments are excluded and disclosed, not silently zero', () => {
|
|
const plain = row({ sessionId: 'plain', cost: 3 })
|
|
const result = applyInvestigation([plain], categoryFilters('coding'))
|
|
expect(result.included).toHaveLength(0)
|
|
expect(result.unattributable).toBe(1)
|
|
// Session-level-only selections still work on plain rows.
|
|
const sessionOnly = applyInvestigation([plain], providerFilters('claude'))
|
|
expect(sessionOnly.cost).toBeCloseTo(3, 10)
|
|
expect(sessionOnly.unattributable).toBe(0)
|
|
})
|
|
|
|
it('subagent rows carry lineage but contribute like any row', () => {
|
|
const child = row({
|
|
sessionId: 'agent-1',
|
|
cost: 0.4,
|
|
parentSessionId: 'root-1',
|
|
isSidechain: true,
|
|
contributions: { segments: [segment({ category: 'coding', cost: 0.4 })] },
|
|
})
|
|
const result = applyInvestigation([child], categoryFilters('coding'))
|
|
expect(result.cost).toBeCloseTo(0.4, 10)
|
|
expect(result.included[0]!.row.parentSessionId).toBe('root-1')
|
|
})
|
|
|
|
it('keys a chip by its full identity, not by the label it truncates for display', () => {
|
|
// Two sessions whose ids share the first 12 characters, and two projects
|
|
// with the same last path segment, render the SAME chip label. The key
|
|
// that identifies the chip to React must still tell them apart.
|
|
const sessionA = { provider: 'claude', sessionId: 'session-abcdefgh-1111' }
|
|
const sessionB = { provider: 'claude', sessionId: 'session-abcdefgh-2222' }
|
|
expect(filterChipLabel('sessions', sessionA)).toBe(filterChipLabel('sessions', sessionB))
|
|
expect(filterChipKey('sessions', sessionA)).not.toBe(filterChipKey('sessions', sessionB))
|
|
|
|
const branchA = { project: '/work/app', branch: 'main' }
|
|
const branchB = { project: '/personal/app', branch: 'main' }
|
|
expect(filterChipLabel('branches', branchA)).toBe(filterChipLabel('branches', branchB))
|
|
expect(filterChipKey('branches', branchA)).not.toBe(filterChipKey('branches', branchB))
|
|
|
|
// A plain dimension keys by its own value, which is already its identity.
|
|
expect(filterChipKey('days', '2026-09-10')).toBe('2026-09-10')
|
|
expect(filterChipKey('prs', 'https://github.com/o/r/pull/7')).toBe('https://github.com/o/r/pull/7')
|
|
})
|
|
})
|