Files
Aditya Vikram Singh 57e897c74a fix(plan): close Copilot nanoAiu twins once per session
foldCopilotSupplementaryTurns will not fold a supplementary twin across
a local-day boundary, so the pair can occupy separate turns. Credit
spend now groups at session scope.
2026-08-24 18:35:59 +05:30

677 lines
21 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { savePlan, type Plan } from '../src/config.js'
import { activePlansFromMap, computePeriodFromResetDay, copilotCreditSpend, getPlanScopedProjects, getPlanUsage, getPlanUsageFromProjects, getPlanUsages } from '../src/plan-usage.js'
import type { ProjectSummary } from '../src/types.js'
const { parseAllSessionsMock } = vi.hoisted(() => ({
parseAllSessionsMock: vi.fn(),
}))
vi.mock('../src/parser.js', () => ({
parseAllSessions: parseAllSessionsMock,
}))
describe('computePeriodFromResetDay', () => {
it('uses current month when today is on/after reset day', () => {
const { periodStart, periodEnd } = computePeriodFromResetDay(1, new Date('2026-04-17T10:00:00.000Z'))
expect(periodStart.getFullYear()).toBe(2026)
expect(periodStart.getMonth()).toBe(3)
expect(periodStart.getDate()).toBe(1)
expect(periodEnd.getMonth()).toBe(4)
expect(periodEnd.getDate()).toBe(1)
})
it('uses previous month when today is before reset day', () => {
const { periodStart, periodEnd } = computePeriodFromResetDay(15, new Date('2026-04-03T10:00:00.000Z'))
expect(periodStart.getMonth()).toBe(2)
expect(periodStart.getDate()).toBe(15)
expect(periodEnd.getMonth()).toBe(3)
expect(periodEnd.getDate()).toBe(15)
})
it('clamps reset day into 1..28', () => {
const { periodStart } = computePeriodFromResetDay(99, new Date('2026-04-27T10:00:00.000Z'))
expect(periodStart.getDate()).toBe(28)
})
})
describe('getPlanScopedProjects supplementary accounting', () => {
const plan: Plan = { id: 'custom', monthlyUsd: 100, provider: 'all', resetDay: 1, setAt: '2026-08-01T00:00:00.000Z' }
const today = new Date('2026-08-10T12:00:00.000Z')
function copilotCall(costUSD: number, timestamp: string, supplementaryAccounting: boolean) {
return {
provider: 'copilot',
model: 'claude-sonnet-4-5',
usage: {
inputTokens: supplementaryAccounting ? 40 : 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp,
bashCommands: [],
deduplicationKey: `copilot-${timestamp}`,
supplementaryAccounting,
}
}
it('weighs calls behaviorally and keeps a cost-bearing zero-call session', () => {
const scoped = getPlanScopedProjects(plan, [
{
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: 1.75,
totalApiCalls: 2,
sessions: [
{
// One real request served alongside its paired store row.
turns: [{
timestamp: '2026-08-05T12:00:00.000Z',
assistantCalls: [
copilotCall(1.0, '2026-08-05T12:00:00.000Z', false),
copilotCall(0.5, '2026-08-05T12:00:05.000Z', true),
],
}],
},
{
// Rollup-only session: real spend, zero behavioral requests.
turns: [{
timestamp: '2026-08-06T12:00:00.000Z',
assistantCalls: [copilotCall(0.25, '2026-08-06T12:00:00.000Z', true)],
}],
},
],
},
] as ProjectSummary[], today)
expect(scoped).toHaveLength(1)
expect(scoped[0]!.sessions.map(session => session.apiCalls)).toEqual([1, 0])
expect(scoped[0]!.sessions.map(session => session.totalCostUSD)).toEqual([1.5, 0.25])
expect(scoped[0]!.totalApiCalls).toBe(1)
expect(scoped[0]!.totalCostUSD).toBeCloseTo(1.75, 10)
expect(getPlanUsageFromProjects(plan, scoped, today).spentApiEquivalentUsd).toBeCloseTo(1.75, 10)
})
})
describe('getPlanUsage', () => {
beforeEach(() => {
parseAllSessionsMock.mockReset()
})
it('passes provider filter from plan and computes status', async () => {
parseAllSessionsMock.mockResolvedValue([
{
totalCostUSD: 160,
sessions: [],
},
])
const usage = await getPlanUsage({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, new Date('2026-04-10T10:00:00.000Z'))
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'claude',
)
expect(usage.spentApiEquivalentUsd).toBe(160)
expect(usage.percentUsed).toBe(80)
expect(usage.status).toBe('near')
})
it('projects using median daily spend (not mean)', async () => {
const dailyCosts = [1, 100, 1, 100, 1, 100, 1]
const turns = dailyCosts.map((cost, idx) => ({
timestamp: `2026-04-${String(idx + 1).padStart(2, '0')}T12:00:00.000Z`,
assistantCalls: [{ costUSD: cost }],
}))
parseAllSessionsMock.mockResolvedValue([
{
totalCostUSD: dailyCosts.reduce((sum, value) => sum + value, 0),
sessions: [{ turns }],
},
])
const usage = await getPlanUsage({
id: 'custom',
monthlyUsd: 500,
provider: 'all',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, new Date('2026-04-07T12:00:00.000Z'))
// Median(1,100,1,100,1,100,1) = 1, so remaining 23 days adds 23.
expect(Math.round(usage.projectedMonthUsd)).toBe(327)
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'all',
)
})
it('computes plan usage from pre-fetched projects', () => {
const usage = getPlanUsageFromProjects({
id: 'custom',
monthlyUsd: 100,
provider: 'all',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, [
{
totalCostUSD: 40,
sessions: [
{
turns: [
{ timestamp: '2026-04-02T12:00:00.000Z', assistantCalls: [{ costUSD: 20 }] },
{ timestamp: '2026-04-03T12:00:00.000Z', assistantCalls: [{ costUSD: 20 }] },
],
},
],
},
], new Date('2026-04-10T10:00:00.000Z'))
expect(usage.spentApiEquivalentUsd).toBe(40)
expect(usage.budgetUsd).toBe(100)
expect(usage.status).toBe('under')
})
it('projects month-end spend from API call timestamps', () => {
const usage = getPlanUsageFromProjects({
id: 'custom',
monthlyUsd: 100,
provider: 'all',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, [
{
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: 10,
totalApiCalls: 1,
sessions: [
{
turns: [
{
timestamp: '2026-03-31T23:59:00.000Z',
assistantCalls: [{ costUSD: 10, timestamp: '2026-04-01T10:00:00.000Z' }],
},
],
},
],
},
] as ProjectSummary[], new Date('2026-04-01T12:00:00.000Z'))
expect(Math.round(usage.projectedMonthUsd)).toBe(300)
})
it('returns active plans in provider display order', () => {
const plans = activePlansFromMap({
codex: {
id: 'custom',
monthlyUsd: 200,
provider: 'codex',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
},
claude: {
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
},
cursor: {
id: 'none',
monthlyUsd: 0,
provider: 'cursor',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
},
})
expect(plans.map(plan => plan.provider)).toEqual(['claude', 'codex'])
})
it('keeps the provider-specific parser filter for one active plan', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-usage-test-'))
process.env['HOME'] = dir
try {
await savePlan({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
})
parseAllSessionsMock.mockResolvedValue([
{
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: 80,
totalApiCalls: 1,
sessions: [],
},
] satisfies ProjectSummary[])
const usages = await getPlanUsages(new Date('2026-04-10T12:00:00.000Z'))
expect(parseAllSessionsMock).toHaveBeenCalledTimes(1)
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'claude',
)
expect(usages).toHaveLength(1)
expect(usages[0]?.spentApiEquivalentUsd).toBe(80)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('computes multiple active plan usages from one all-provider parse', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-usage-test-'))
process.env['HOME'] = dir
try {
await savePlan({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
})
await savePlan({
id: 'custom',
monthlyUsd: 100,
provider: 'codex',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
})
parseAllSessionsMock.mockResolvedValue([
{
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: 150,
totalApiCalls: 2,
sessions: [
{
sessionId: 'session-1',
project: 'codeburn',
firstTimestamp: '2026-04-03T10:00:00.000Z',
lastTimestamp: '2026-04-03T11:00:00.000Z',
totalCostUSD: 150,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 2,
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {},
skillBreakdown: {},
turns: [
{
userMessage: 'work',
timestamp: '2026-04-03T10:00:00.000Z',
sessionId: 'session-1',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [
{
provider: 'claude',
model: 'claude-opus-4-7',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 100,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-04-03T10:00:00.000Z',
bashCommands: [],
deduplicationKey: 'claude-1',
},
{
provider: 'codex',
model: 'gpt-5.5',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 50,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-04-03T11:00:00.000Z',
bashCommands: [],
deduplicationKey: 'codex-1',
},
],
},
],
},
],
},
] satisfies ProjectSummary[])
const usages = await getPlanUsages(new Date('2026-04-10T12:00:00.000Z'))
expect(parseAllSessionsMock).toHaveBeenCalledTimes(1)
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'all',
)
expect(usages.map(usage => usage.plan.provider)).toEqual(['claude', 'codex'])
expect(usages.map(usage => usage.spentApiEquivalentUsd)).toEqual([100, 50])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})
function usageCall(overrides: {
provider: string
costUSD: number
timestamp: string
nanoAiu?: number
supplementaryAccounting?: boolean
}) {
return {
provider: overrides.provider,
model: 'claude-sonnet-4-5',
usage: {
inputTokens: 100,
outputTokens: 20,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: overrides.costUSD,
tools: [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard' as const,
timestamp: overrides.timestamp,
bashCommands: [],
deduplicationKey: `${overrides.provider}-${overrides.timestamp}-${overrides.nanoAiu ?? 'none'}`,
...(overrides.nanoAiu != null ? { nanoAiu: overrides.nanoAiu } : {}),
...(overrides.supplementaryAccounting ? { supplementaryAccounting: true } : {}),
}
}
function usageProjectFromTurns(turns: ReturnType<typeof usageCall>[][]): ProjectSummary {
const calls = turns.flat()
return {
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: calls.reduce((sum, call) => sum + call.costUSD, 0),
totalApiCalls: calls.length,
sessions: [{
turns: turns.map(assistantCalls => ({
timestamp: assistantCalls[0]!.timestamp,
assistantCalls,
})),
}],
} as ProjectSummary
}
function usageProject(calls: ReturnType<typeof usageCall>[]): ProjectSummary {
return usageProjectFromTurns([calls])
}
describe('copilot AI credit plan math', () => {
const today = new Date('2026-08-10T12:00:00.000Z')
const copilotPro: Plan = {
id: 'copilot-pro',
monthlyCredits: 1500,
monthlyUsd: 15,
provider: 'copilot',
resetDay: 1,
setAt: '2026-08-01T00:00:00.000Z',
}
const claudePro: Plan = {
id: 'claude-pro',
monthlyUsd: 20,
provider: 'claude',
resetDay: 1,
setAt: '2026-08-01T00:00:00.000Z',
}
it('turns 1.5e9 nanoAiu into 1.5 / 1500 credits (0.1%)', () => {
const usage = getPlanUsageFromProjects(copilotPro, [
usageProject([
usageCall({
provider: 'copilot',
costUSD: 42,
timestamp: '2026-08-05T12:00:00.000Z',
nanoAiu: 1_500_000_000,
}),
]),
], today)
expect(usage.spentCredits).toBe(1.5)
expect(usage.budgetCredits).toBe(1500)
expect(usage.percentUsed).toBeCloseTo(0.1, 10)
expect(usage.spentApiEquivalentUsd).toBeCloseTo(0.015, 10)
expect(usage.creditsIncomplete).toBe(false)
expect(usage.status).toBe('under')
})
it('does not fill the credits bar from token costUSD when nanoAiu is missing', () => {
const usage = getPlanUsageFromProjects(copilotPro, [
usageProject([
usageCall({
provider: 'copilot',
costUSD: 42,
timestamp: '2026-08-05T12:00:00.000Z',
}),
]),
], today)
expect(usage.spentCredits).toBe(0)
expect(usage.creditsIncomplete).toBe(true)
expect(usage.percentUsed).toBe(0)
expect(usage.spentApiEquivalentUsd).toBe(0)
})
it('counts a paired store-row once and ignores JSONL / rollup siblings', () => {
const usage = getPlanUsageFromProjects(copilotPro, [
usageProject([
usageCall({
provider: 'copilot',
costUSD: 0.001605,
timestamp: '2026-08-05T12:00:00.000Z',
nanoAiu: 1_500_000_000,
supplementaryAccounting: true,
}),
usageCall({
provider: 'copilot',
costUSD: 0.001605,
timestamp: '2026-08-05T12:00:01.000Z',
}),
usageCall({
provider: 'copilot',
costUSD: 0.25,
timestamp: '2026-08-06T12:00:00.000Z',
supplementaryAccounting: true,
}),
]),
], today)
expect(usage.spentCredits).toBe(1.5)
expect(usage.percentUsed).toBeCloseTo(0.1, 10)
expect(usage.creditsIncomplete).toBe(true)
})
it('does not double credits when a paired rollup also carries nanoAiu', () => {
const usage = getPlanUsageFromProjects(copilotPro, [
usageProject([
usageCall({
provider: 'copilot',
costUSD: 0.001605,
timestamp: '2026-08-05T12:00:00.000Z',
nanoAiu: 1_500_000_000,
supplementaryAccounting: true,
}),
usageCall({
provider: 'copilot',
costUSD: 0.001605,
timestamp: '2026-08-05T12:00:01.000Z',
nanoAiu: 1_500_000_000,
}),
]),
], today)
expect(usage.spentCredits).toBe(1.5)
expect(usage.creditsIncomplete).toBe(false)
})
it('does not double credits when nanoAiu twins sit in separate session turns', () => {
// foldCopilotSupplementaryTurns refuses a local-day-boundary pair, so the
// supplementary twin stays on its own turn. Credit math must still close
// once at session scope.
const projects = [
usageProjectFromTurns([
[usageCall({
provider: 'copilot',
costUSD: 0.001605,
timestamp: '2026-08-05T18:25:00.000Z',
nanoAiu: 1_500_000_000,
})],
[usageCall({
provider: 'copilot',
costUSD: 0.001605,
timestamp: '2026-08-05T18:35:00.000Z',
nanoAiu: 1_500_000_000,
supplementaryAccounting: true,
})],
]),
]
expect(copilotCreditSpend(projects).spentCredits).toBe(1.5)
const usage = getPlanUsageFromProjects(copilotPro, projects, today)
expect(usage.spentCredits).toBe(1.5)
expect(usage.creditsIncomplete).toBe(false)
})
it('keeps a $0 nanoAiu-only session on copilot plans and drops it on USD plans', () => {
const projects = [
usageProject([
usageCall({
provider: 'copilot',
costUSD: 0,
timestamp: '2026-08-05T12:00:00.000Z',
nanoAiu: 1_500_000_000,
supplementaryAccounting: true,
}),
]),
]
const allUsd: Plan = {
id: 'custom',
monthlyUsd: 100,
provider: 'all',
resetDay: 1,
setAt: '2026-08-01T00:00:00.000Z',
}
expect(getPlanScopedProjects(copilotPro, projects, today)).toHaveLength(1)
expect(getPlanScopedProjects(allUsd, projects, today)).toHaveLength(0)
expect(getPlanScopedProjects(claudePro, projects, today)).toHaveLength(0)
})
it('keeps Claude plans on costUSD against the same mixed fixture', () => {
const projects = [
usageProject([
usageCall({
provider: 'copilot',
costUSD: 42,
timestamp: '2026-08-05T12:00:00.000Z',
nanoAiu: 1_500_000_000,
}),
usageCall({
provider: 'claude',
costUSD: 5,
timestamp: '2026-08-05T12:00:00.000Z',
}),
]),
]
const scopedClaude = getPlanScopedProjects(claudePro, projects, today)
const claudeUsage = getPlanUsageFromProjects(claudePro, scopedClaude, today)
const copilotUsage = getPlanUsageFromProjects(copilotPro, getPlanScopedProjects(copilotPro, projects, today), today)
expect(claudeUsage.spentApiEquivalentUsd).toBe(5)
expect(claudeUsage.spentCredits).toBeUndefined()
expect(claudeUsage.percentUsed).toBe(25)
expect(copilotUsage.spentCredits).toBe(1.5)
expect(copilotUsage.percentUsed).toBeCloseTo(0.1, 10)
})
it('leaves cursor and grok USD siblings at 25%', () => {
const cursor = getPlanUsageFromProjects({
id: 'cursor-pro',
monthlyUsd: 20,
provider: 'cursor',
resetDay: 1,
setAt: '2026-08-01T00:00:00.000Z',
}, [usageProject([usageCall({ provider: 'cursor', costUSD: 5, timestamp: '2026-08-05T12:00:00.000Z' })])], today)
const grok = getPlanUsageFromProjects({
id: 'supergrok',
monthlyUsd: 30,
provider: 'grok',
resetDay: 1,
setAt: '2026-08-01T00:00:00.000Z',
}, [usageProject([usageCall({ provider: 'grok', costUSD: 7.5, timestamp: '2026-08-05T12:00:00.000Z' })])], today)
expect(cursor.percentUsed).toBe(25)
expect(grok.percentUsed).toBe(25)
expect(cursor.spentCredits).toBeUndefined()
expect(grok.spentCredits).toBeUndefined()
})
})