feat(menubar): expose combined multi-device usage in menubar-json (#566) (#567)

* feat(menubar): expose combined multi-device usage in menubar-json (#566)

The macOS menubar reads `codeburn status --format menubar-json`, which only
ever reflected the local machine. Multi-device "Combined" usage existed only in
the terminal `devices` command. This exposes combined + per-device usage through
the menubar-json contract so the menubar can mirror the combined dashboard.

- Extract the per-device summary + combined reduce that was inline in
  renderDevices into a reusable pure `summarizeDeviceUsage(results, window?)`.
  Error/unreachable devices are excluded from the combined sums (kept in
  perDevice); deviceCount counts all, reachableCount counts the reachable ones.
  renderDevices now formats from it with byte-identical output.
- Add an optional `combined?: CombinedUsage` block to MenubarPayload (perDevice
  list + combined totals incl. calls/sessions). Absent by default.
- Add `--scope local|combined` to `status` (default local). `combined` builds the
  local payload, pulls paired devices (pullDevices isolates per-peer failures),
  and attaches the summary.
- Correctness guards: reject `--scope combined` with `--days` (non-contiguous,
  not representable over the sharing query) and with `--provider`/`--project`/
  `--exclude` (the sharing query carries no filters, so peers would report
  unfiltered usage). Window-scope the cache-token sum to the selected period
  (cache lives in 365-day daily history; current carries no cache counts).

TS/CLI only. The menubar Local/Combined toggle + render is a follow-up.

* fix(menubar): never let combined enrichment break the base local payload

The status --format menubar-json --scope combined path pulls paired devices.
Wrap that best-effort enrichment in a guard so an unexpected failure (corrupt
remotes store, peer I/O) can never take down the base local menubar payload —
on error the local payload is still emitted with combined omitted. Add a test
that a corrupt remote-devices.json still yields a valid combined (local-only) payload.

---------

Co-authored-by: AgentSeal <hello@agentseal.org>
This commit is contained in:
ozymandiashh 2026-06-28 21:59:23 +03:00 committed by GitHub
parent b24dd31ede
commit 88c1cee467
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 597 additions and 54 deletions

View file

@ -17,10 +17,11 @@ import { renderOverview } from './overview.js'
import { runWebDashboard } from './web-dashboard.js'
import { hostname } from 'os'
import { runShareServer } from './sharing/share-run.js'
import { addRemote, linkRemote, pullDevices, renderDevices } from './sharing/host.js'
import { addRemote, linkRemote, pullDevices, renderDevices, summarizeDeviceUsage } from './sharing/host.js'
import { browse } from './sharing/discovery.js'
import { promptChoice } from './sharing/prompt.js'
import { loadRemotes, saveRemotes } from './sharing/store.js'
import type { UsageQuery } from './sharing/share-server.js'
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize } from './optimize.js'
import { renderCompare } from './compare.js'
@ -172,6 +173,15 @@ function assertProvider(value: string, command: string): void {
process.exit(1)
}
function assertScope(value: string, allowed: readonly string[], command: string): void {
if (!allowed.includes(value)) {
process.stderr.write(
`codeburn ${command}: unknown scope "${value}". Valid values: ${allowed.join(', ')}.\n`
)
process.exit(1)
}
}
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
@ -630,6 +640,7 @@ program
.command('status')
.description('Compact status output (today + month)')
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
.option('--scope <scope>', 'Usage scope for menubar-json: local, combined', 'local')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
@ -641,6 +652,7 @@ program
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.action(async (opts) => {
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
assertScope(opts.scope, ['local', 'combined'], 'status')
assertProvider(opts.provider, 'status')
if (opts.day && (opts.from || opts.to)) {
process.stderr.write('error: --day cannot be combined with --from or --to\n')
@ -650,6 +662,14 @@ program
process.stderr.write('error: --days cannot be combined with --day, --from, or --to\n')
process.exit(1)
}
if (opts.format === 'menubar-json' && opts.scope === 'combined' && opts.days) {
process.stderr.write('error: --scope combined cannot be combined with --days\n')
process.exit(1)
}
if (opts.scope === 'combined' && (opts.provider !== 'all' || opts.project.length > 0 || opts.exclude.length > 0)) {
process.stderr.write('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)\n')
process.exit(1)
}
await loadPricing()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
@ -669,6 +689,27 @@ program
daysSelection,
optimize: opts.optimize !== false,
})
if (opts.scope === 'combined') {
// Combined multi-device usage is best-effort enrichment on the menubar's
// hot path. Never let pulling peers (or a corrupt remotes store) take
// down the base local payload: on any failure, emit local data with
// `combined` omitted so the menubar always gets a valid response.
try {
const query: UsageQuery = customRange
? { from: opts.from, to: opts.to }
: daySelection
? { from: daySelection.day, to: daySelection.day }
: { period: opts.period }
const localGetUsage = async (): Promise<typeof payload> => payload
const results = await pullDevices(localGetUsage, query, hostname(), {})
payload.combined = summarizeDeviceUsage(results, {
start: toDateString(periodInfo.range.start),
end: toDateString(periodInfo.range.end),
})
} catch {
// best-effort only: the local payload is still emitted below
}
}
console.log(JSON.stringify(payload))
return
}

View file

@ -77,6 +77,37 @@ export type LocalModelSavings = {
byProvider: Array<{ name: string; calls: number; savingsUSD: number }>
}
export type DeviceSummary = {
id: string
name: string
local: boolean
error?: string
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
}
export type CombinedUsage = {
perDevice: DeviceSummary[]
combined: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
deviceCount: number
reachableCount: number
}
}
export type MenubarPayload = {
generated: string
current: {
@ -180,6 +211,7 @@ export type MenubarPayload = {
history: {
daily: DailyHistoryEntry[]
}
combined?: CombinedUsage
}
function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null {

View file

@ -5,16 +5,23 @@ import { sanitizeForSharing } from './sanitize.js'
import type { DiscoveredDevice } from './discovery.js'
import type { UsageQuery } from './share-server.js'
import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js'
import type { MenubarPayload } from '../menubar-json.js'
import type { CombinedUsage, DeviceSummary, MenubarPayload } from '../menubar-json.js'
import { formatCost } from '../currency.js'
import { renderTable } from '../text-table.js'
import { Chalk } from 'chalk'
export type { CombinedUsage, DeviceSummary } from '../menubar-json.js'
// Minimal shape we read from a device's usage payload (the menubar payload).
// Cache create/read are only in the daily history, so we sum those.
type DevicePayload = {
current?: { cost?: number; calls?: number; sessions?: number; inputTokens?: number; outputTokens?: number }
history?: { daily?: Array<{ cacheReadTokens?: number; cacheWriteTokens?: number }> }
history?: { daily?: Array<{ date?: string; cacheReadTokens?: number; cacheWriteTokens?: number }> }
}
type SummaryWindow = {
start: string
end: string
}
export type DeviceUsage = {
@ -25,6 +32,80 @@ export type DeviceUsage = {
error?: string
}
const zeroUsage = {
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
}
function num(n: number | undefined): number {
return n ?? 0
}
function summarizeOneDevice(d: DeviceUsage, window?: SummaryWindow): DeviceSummary {
const error = d.error !== undefined ? d.error : d.payload === undefined ? 'no usage payload' : undefined
if (error !== undefined || d.payload === undefined) {
return {
id: d.id,
name: d.name,
local: d.local,
error,
...zeroUsage,
}
}
const cur = d.payload.current
const daily = (d.payload.history?.daily ?? []).filter((e) => {
if (window === undefined) return true
return e.date !== undefined && window.start <= e.date && e.date <= window.end
})
const inputTokens = num(cur?.inputTokens)
const outputTokens = num(cur?.outputTokens)
const cacheCreateTokens = daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0)
const cacheReadTokens = daily.reduce((s, e) => s + num(e.cacheReadTokens), 0)
return {
id: d.id,
name: d.name,
local: d.local,
cost: num(cur?.cost),
calls: num(cur?.calls),
sessions: num(cur?.sessions),
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens: inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens,
}
}
export function summarizeDeviceUsage(results: DeviceUsage[], window?: SummaryWindow): CombinedUsage {
const perDevice = results.map((d) => summarizeOneDevice(d, window))
const combined = perDevice.reduce(
(a, d) => {
if (d.error !== undefined) return a
return {
cost: a.cost + d.cost,
calls: a.calls + d.calls,
sessions: a.sessions + d.sessions,
inputTokens: a.inputTokens + d.inputTokens,
outputTokens: a.outputTokens + d.outputTokens,
cacheCreateTokens: a.cacheCreateTokens + d.cacheCreateTokens,
cacheReadTokens: a.cacheReadTokens + d.cacheReadTokens,
totalTokens: a.totalTokens + d.totalTokens,
deviceCount: a.deviceCount,
reachableCount: a.reachableCount + 1,
}
},
{ ...zeroUsage, deviceCount: perDevice.length, reachableCount: 0 },
)
return { perDevice, combined }
}
function parseHostPort(input: string, defaultPort: number): { host: string; port: number } {
const idx = input.lastIndexOf(':')
if (idx > 0 && /^\d+$/.test(input.slice(idx + 1))) {
@ -118,38 +199,20 @@ export async function pullDevices(
// Joined "Totals by machine" report: one row per device plus a bold Combined
// row. Tokens are shown as full, comma-grouped numbers.
export function renderDevices(results: DeviceUsage[]): string {
const num = (n: number | undefined): number => n ?? 0
const n = (x: number): string => Math.round(x).toLocaleString()
const money = (x: number): string => formatCost(x).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,')
const rows = results.map((d) => {
const cur = d.payload?.current
const daily = d.payload?.history?.daily ?? []
const input = num(cur?.inputTokens)
const output = num(cur?.outputTokens)
const cacheCreate = daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0)
const cacheRead = daily.reduce((s, e) => s + num(e.cacheReadTokens), 0)
return {
name: d.name + (d.local ? ' (this Mac)' : ''),
error: d.error,
cost: num(cur?.cost),
input,
output,
cacheCreate,
cacheRead,
total: input + output + cacheCreate + cacheRead,
}
})
const combined = rows.reduce(
(a, r) => ({
cost: a.cost + r.cost,
input: a.input + r.input,
output: a.output + r.output,
cacheCreate: a.cacheCreate + r.cacheCreate,
cacheRead: a.cacheRead + r.cacheRead,
total: a.total + r.total,
}),
{ cost: 0, input: 0, output: 0, cacheCreate: 0, cacheRead: 0, total: 0 },
)
const summary = summarizeDeviceUsage(results)
const rows = summary.perDevice.map((d) => ({
name: d.name + (d.local ? ' (this Mac)' : ''),
error: d.error,
cost: d.cost,
input: d.inputTokens,
output: d.outputTokens,
cacheCreate: d.cacheCreateTokens,
cacheRead: d.cacheReadTokens,
total: d.totalTokens,
}))
const combined = summary.combined
const tableRows = [
...rows.map((r) =>
@ -157,7 +220,7 @@ export function renderDevices(results: DeviceUsage[]): string {
? [r.name, r.error, '-', '-', '-', '-', '-']
: [r.name, money(r.cost), n(r.total), n(r.input), n(r.output), n(r.cacheCreate), n(r.cacheRead)],
),
['Combined', money(combined.cost), n(combined.total), n(combined.input), n(combined.output), n(combined.cacheCreate), n(combined.cacheRead)],
['Combined', money(combined.cost), n(combined.totalTokens), n(combined.inputTokens), n(combined.outputTokens), n(combined.cacheCreateTokens), n(combined.cacheReadTokens)],
]
const table = renderTable(
[

View file

@ -154,4 +154,201 @@ describe('codeburn status --format menubar-json', () => {
await rm(home, { recursive: true, force: true })
}
})
it('attaches combined local-only usage for --scope combined and omits it for local scope', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts1),
assistantLine('s1', ts2, 'msg-1'),
].join('\n'),
)
const combinedResult = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'combined',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(combinedResult.status, `stderr: ${combinedResult.stderr}`).toBe(0)
const payload = JSON.parse(combinedResult.stdout) as {
current: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
}
history: {
daily: Array<{ cacheWriteTokens?: number; cacheReadTokens?: number }>
}
combined?: {
perDevice: Array<{
id: string
local: boolean
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
}>
combined: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
deviceCount: number
reachableCount: number
}
}
}
expect(payload.combined).toBeDefined()
expect(payload.combined!.perDevice).toHaveLength(1)
const local = payload.combined!.perDevice[0]!
const cacheCreateTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheWriteTokens ?? 0), 0)
const cacheReadTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheReadTokens ?? 0), 0)
const totalTokens = payload.current.inputTokens + payload.current.outputTokens + cacheCreateTokens + cacheReadTokens
expect(local).toMatchObject({
id: 'local',
local: true,
cost: payload.current.cost,
calls: payload.current.calls,
sessions: payload.current.sessions,
inputTokens: payload.current.inputTokens,
outputTokens: payload.current.outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens,
})
expect(payload.combined!.combined).toEqual({
cost: payload.current.cost,
calls: payload.current.calls,
sessions: payload.current.sessions,
inputTokens: payload.current.inputTokens,
outputTokens: payload.current.outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens,
deviceCount: 1,
reachableCount: 1,
})
const localResult = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'local',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(localResult.status, `stderr: ${localResult.stderr}`).toBe(0)
expect(JSON.parse(localResult.stdout)).not.toHaveProperty('combined')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it.each([
['--provider', ['--provider', 'claude']],
['--project', ['--project', 'x']],
['--exclude', ['--exclude', 'y']],
])('rejects combined scope with filtered local payloads from %s', async (_name, filterArgs) => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-filter-'))
try {
const result = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'combined',
...filterArgs,
'--no-optimize',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects invalid menubar-json scope values', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-scope-'))
try {
const result = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'remote',
'--no-optimize',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('unknown scope "remote"')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('still emits a valid combined menubar payload when the remotes store is corrupt', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-corrupt-remotes-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(join(projectDir, 'session.jsonl'), [userLine('s1', ts1), assistantLine('s1', ts2, 'msg-1')].join('\n'))
// Corrupt the remotes store the combined path reads. The menubar must
// still get a valid payload (combined degrades to local-only).
const sharingDir = join(home, '.config', 'codeburn', 'sharing')
await mkdir(sharingDir, { recursive: true })
await writeFile(join(sharingDir, 'remote-devices.json'), '{ this is : not valid json ]')
const result = runCli([
'status', '--format', 'menubar-json', '--scope', 'combined',
'--period', 'today', '--no-optimize',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: { cost: number }
combined?: { perDevice: unknown[]; combined: { deviceCount: number; reachableCount: number } }
}
expect(payload.combined).toBeDefined()
expect(payload.combined!.perDevice).toHaveLength(1)
expect(payload.combined!.combined.deviceCount).toBe(1)
expect(payload.combined!.combined.reachableCount).toBe(1)
} finally {
await rm(home, { recursive: true, force: true })
}
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { buildMenubarPayload, type PeriodData, type ProviderCost } from '../src/menubar-json.js'
import { buildMenubarPayload, type CombinedUsage, type PeriodData, type ProviderCost } from '../src/menubar-json.js'
import type { OptimizeResult } from '../src/optimize.js'
function emptyPeriod(label: string): PeriodData {
@ -231,4 +231,42 @@ describe('buildMenubarPayload', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
expect(payload.current.providers).toEqual({ claude: 76.45 })
})
it('omits combined usage by default and accepts the documented combined shape when attached', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
expect(payload).not.toHaveProperty('combined')
const combined: CombinedUsage = {
perDevice: [
{
id: 'local',
name: 'Mac Studio',
local: true,
cost: 1,
calls: 2,
sessions: 1,
inputTokens: 100,
outputTokens: 50,
cacheCreateTokens: 10,
cacheReadTokens: 20,
totalTokens: 180,
},
],
combined: {
cost: 1,
calls: 2,
sessions: 1,
inputTokens: 100,
outputTokens: 50,
cacheCreateTokens: 10,
cacheReadTokens: 20,
totalTokens: 180,
deviceCount: 1,
reachableCount: 1,
},
}
payload.combined = combined
expect(payload.combined).toEqual(combined)
})
})

View file

@ -1,34 +1,38 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { generateIdentity } from '../../src/sharing/identity.js'
import { PeerStore } from '../../src/sharing/pairing.js'
import { ShareServer } from '../../src/sharing/share-server.js'
import { addRemote, pullDevices, renderDevices, type DeviceUsage } from '../../src/sharing/host.js'
import { addRemote, pullDevices, renderDevices, summarizeDeviceUsage, type DeviceUsage } from '../../src/sharing/host.js'
describe('host device flow (loopback)', () => {
let server: ShareServer
let port: number
const clientMock = vi.hoisted(() => ({
hello: vi.fn(),
pair: vi.fn(),
pairRequest: vi.fn(),
fetchUsage: vi.fn(),
}))
vi.mock('../../src/sharing/client.js', () => clientMock)
describe('host device flow', () => {
let dir: string
const remoteUsage = { current: { cost: 100, calls: 10, sessions: 2, inputTokens: 1000, outputTokens: 200 } }
beforeAll(async () => {
beforeEach(async () => {
vi.clearAllMocks()
dir = await mkdtemp(join(tmpdir(), 'cb-host-'))
const serverId = await generateIdentity('MacBook')
server = new ShareServer({ identity: serverId, peers: new PeerStore(), getUsage: async () => remoteUsage })
port = await server.listen(0, '127.0.0.1')
})
afterAll(async () => {
await server.close()
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
it('pairs, persists, pulls both devices, and combines', async () => {
const pin = server.openPairing()
const device = await addRemote(`127.0.0.1:${port}`, pin, { defaultPort: port, dir })
const remoteUsage = { current: { cost: 100, calls: 10, sessions: 2, inputTokens: 1000, outputTokens: 200 } }
clientMock.hello.mockResolvedValue({ status: 200, json: { fingerprint: 'remote-fp', name: 'MacBook' } })
clientMock.pair.mockResolvedValue({ status: 200, json: { token: 'remote-token' } })
clientMock.fetchUsage.mockResolvedValue({ status: 200, json: remoteUsage })
const device = await addRemote('127.0.0.1:7777', '123456', { defaultPort: 7777, dir })
expect(device.name).toBe('MacBook')
expect(device.token).toBeTruthy()
@ -41,6 +45,11 @@ describe('host device flow (loopback)', () => {
const remote = results.find((r) => !r.local)!
expect(remote.name).toBe('MacBook')
expect(remote.payload!.current!.cost).toBe(100)
expect(clientMock.fetchUsage).toHaveBeenCalledWith(
expect.objectContaining({ host: '127.0.0.1', port: 7777, expectedFingerprint: 'remote-fp' }),
'remote-token',
{ period: 'month' },
)
const text = renderDevices(results)
expect(text).toContain('Mac Studio (this Mac)')
@ -51,11 +60,174 @@ describe('host device flow (loopback)', () => {
it('renders an unreachable device as an error without dropping the combined row', () => {
const results: DeviceUsage[] = [
{ name: 'Mac Studio', local: true, payload: { current: { cost: 10, calls: 1, sessions: 1, inputTokens: 1, outputTokens: 1 } } },
{ name: 'MacBook', local: false, error: 'connection refused' },
{ id: 'local', name: 'Mac Studio', local: true, payload: { current: { cost: 10, calls: 1, sessions: 1, inputTokens: 1, outputTokens: 1 } } },
{ id: 'remote-1', name: 'MacBook', local: false, error: 'connection refused' },
]
const text = renderDevices(results)
expect(text).toContain('connection refused')
expect(text).toContain('Combined')
})
it('summarizes reachable devices and excludes error rows from combined totals', () => {
const results: DeviceUsage[] = [
{
id: 'local',
name: 'Mac Studio',
local: true,
payload: {
current: { cost: 10, calls: 2, sessions: 1, inputTokens: 100, outputTokens: 40 },
history: {
daily: [
{ cacheWriteTokens: 5, cacheReadTokens: 10 },
{ cacheWriteTokens: 7, cacheReadTokens: 3 },
],
},
},
},
{
id: 'remote-1',
name: 'MacBook',
local: false,
payload: {
current: { cost: 3, calls: 4, sessions: 2, inputTokens: 20, outputTokens: 30 },
history: { daily: [{ cacheWriteTokens: 2, cacheReadTokens: 8 }] },
},
},
{ id: 'remote-err', name: 'Offline', local: false, error: 'timeout' },
]
const summary = summarizeDeviceUsage(results)
expect(summary.perDevice).toEqual([
{
id: 'local',
name: 'Mac Studio',
local: true,
cost: 10,
calls: 2,
sessions: 1,
inputTokens: 100,
outputTokens: 40,
cacheCreateTokens: 12,
cacheReadTokens: 13,
totalTokens: 165,
},
{
id: 'remote-1',
name: 'MacBook',
local: false,
cost: 3,
calls: 4,
sessions: 2,
inputTokens: 20,
outputTokens: 30,
cacheCreateTokens: 2,
cacheReadTokens: 8,
totalTokens: 60,
},
{
id: 'remote-err',
name: 'Offline',
local: false,
error: 'timeout',
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
},
])
expect(summary.combined).toEqual({
cost: 13,
calls: 6,
sessions: 3,
inputTokens: 120,
outputTokens: 70,
cacheCreateTokens: 14,
cacheReadTokens: 21,
totalTokens: 225,
deviceCount: 3,
reachableCount: 2,
})
})
it('scopes cache-token summaries to the optional window without changing no-window totals', () => {
const results: DeviceUsage[] = [
{
id: 'local',
name: 'Mac Studio',
local: true,
payload: {
current: { cost: 1, calls: 1, sessions: 1, inputTokens: 100, outputTokens: 50 },
history: {
daily: [
{ date: '2026-04-09', cacheWriteTokens: 100, cacheReadTokens: 1000 },
{ date: '2026-04-10', cacheWriteTokens: 5, cacheReadTokens: 10 },
{ date: '2026-04-11', cacheWriteTokens: 7, cacheReadTokens: 3 },
],
},
},
},
{
id: 'remote-1',
name: 'MacBook',
local: false,
payload: {
current: { cost: 2, calls: 2, sessions: 1, inputTokens: 20, outputTokens: 30 },
history: {
daily: [
{ date: '2026-04-08', cacheWriteTokens: 11, cacheReadTokens: 13 },
{ date: '2026-04-10', cacheWriteTokens: 2, cacheReadTokens: 8 },
],
},
},
},
]
const all = summarizeDeviceUsage(results)
expect(all.perDevice[0]).toMatchObject({
cacheCreateTokens: 112,
cacheReadTokens: 1013,
totalTokens: 1275,
})
expect(all.perDevice[1]).toMatchObject({
cacheCreateTokens: 13,
cacheReadTokens: 21,
totalTokens: 84,
})
expect(all.combined).toMatchObject({
inputTokens: 120,
outputTokens: 80,
cacheCreateTokens: 125,
cacheReadTokens: 1034,
totalTokens: 1359,
})
const scoped = summarizeDeviceUsage(results, { start: '2026-04-10', end: '2026-04-10' })
expect(scoped.perDevice[0]).toMatchObject({
cacheCreateTokens: 5,
cacheReadTokens: 10,
totalTokens: 165,
})
expect(scoped.perDevice[1]).toMatchObject({
cacheCreateTokens: 2,
cacheReadTokens: 8,
totalTokens: 60,
})
expect(scoped.combined).toMatchObject({
cost: 3,
calls: 3,
sessions: 2,
inputTokens: 120,
outputTokens: 80,
cacheCreateTokens: 7,
cacheReadTokens: 18,
totalTokens: 225,
deviceCount: 2,
reachableCount: 2,
})
})
})