Merge pull request #889 from ozymandiashh/fix/865-provider-list-project-filter
Some checks are pending
CI / semgrep (push) Waiting to run

fix(menubar): apply --project/--exclude to the provider list on carried days
This commit is contained in:
Resham Joshi 2026-08-03 17:06:32 -07:00 • committed by GitHub
commit 2c3319b286
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 71 additions and 6 deletions

View file

@ -634,11 +634,18 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
providers.push({ name: 'claude', displayName: displayNameByName.get('claude') ?? 'Claude', cost: 0 })
}
} else if (isAllProviders) {
const unfilteredProviderDays = [
...(rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) : []),
...(await getTodayAllDays()).filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr),
]
const allDaysForProviders = daysSelection ? unfilteredProviderDays.filter(d => daysSelection.days.has(d.date)) : unfilteredProviderDays
// Reuse the day set the headline was built from instead of rebuilding one
// straight out of the cache. The rebuilt version unioned unfiltered historical
// days with an already-filtered today, so per-provider costs counted every
// carried day whole while today honoured --project/--exclude, and the provider
// list could not be reconciled with the By Project panel (#865).
//
// durable.days is that same union, already narrowed by range, day selection and
// the project filter, which is what the comment above the buildDurablePeriod
// call already promised this section would use. Non-null here: this branch
// implies !isClaudeConfigScoped, which forces the !effectivelyScoped path that
// assigns it.
const allDaysForProviders = cacheDaysForPeriod ?? []
const providerTotals: Record<string, number> = {}
for (const d of allDaysForProviders) {
for (const [name, p] of Object.entries(d.providers)) {

View file

@ -4,7 +4,7 @@ import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from '../src/daily-cache.js'
import { loadPricing } from '../src/models.js'
import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
@ -83,6 +83,34 @@ function carriedDayWithoutProjects(date: string): DailyEntry {
return day
}
/**
* A carried day whose two providers own disjoint projects: claude spent only on
* `keep-me`, codex only on `drop-me`. Filtering `drop-me` out must therefore take
* codex's whole contribution with it, which is what makes the provider list's
* project slicing observable.
*/
function carriedDayTwoProviders(date: string): DailyEntry {
const day = carriedDayWithProjects(date)
const keepProjects = { 'keep-me': { cost: KEEP.cost, calls: KEEP.calls, savingsUSD: 0, sessions: KEEP.sessions, path: '/Users/gone/keep-me' } }
const dropProjects = { 'drop-me': { cost: DROP.cost, calls: DROP.calls, savingsUSD: 0, sessions: DROP.sessions, path: '/Users/gone/drop-me' } }
const slice = (
stats: { cost: number; calls: number; sessions: number },
projects: Record<string, ProjectDayStats>,
): ProviderDaySlice => ({
calls: stats.calls, cost: stats.cost, savingsUSD: 0, sessions: stats.sessions,
inputTokens: 2500, outputTokens: 1000, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 2, oneShotTurns: 1,
models: { 'Opus 4.8': { calls: stats.calls, cost: stats.cost, savingsUSD: 0, inputTokens: 2500, outputTokens: 1000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 5, cost: stats.cost, savingsUSD: 0, editTurns: 2, oneShotTurns: 1 } },
projects,
})
day.providers = {
claude: slice(KEEP, keepProjects),
codex: slice(DROP, dropProjects),
}
return day
}
async function seedCache(...days: DailyEntry[]): Promise<void> {
const cache: DailyCache = {
version: DAILY_CACHE_VERSION,
@ -277,6 +305,36 @@ describe('durable headline honours --project / --exclude on carried days', () =>
expect(menubar.current.inputTokens).toBe(durable.data.inputTokens)
})
it('slices the provider list by the project filter so it reconciles with the headline', async () => {
await seedCache(carriedDayTwoProviders(daysAgoStr(10)))
const range = coveringRange()
clearSessionCache()
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false })
const providers = menubar.current.providers
const providerSum = Object.values(providers).reduce((a, b) => a + b, 0)
// The headline already honours the filter; the provider list used to be built
// from unfiltered cache days, so it kept reporting codex's excluded spend.
expect(menubar.current.cost).toBeCloseTo(KEEP.cost, 6)
expect(providerSum).toBeCloseTo(menubar.current.cost, 6)
expect(providers['claude']).toBeCloseTo(KEEP.cost, 6)
expect(providers['codex'] ?? 0).toBeCloseTo(0, 6)
})
it('leaves the provider list untouched when no project filter is given', async () => {
await seedCache(carriedDayTwoProviders(daysAgoStr(10)))
const range = coveringRange()
clearSessionCache()
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', optimize: false, timeline: false })
expect(menubar.current.providers['claude']).toBeCloseTo(KEEP.cost, 6)
expect(menubar.current.providers['codex']).toBeCloseTo(DROP.cost, 6)
expect(menubar.current.cost).toBeCloseTo(DAY_COST, 6)
})
it('leaves the unfiltered headline exactly as it was', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()